primitive-admin 1.0.53 → 1.0.55
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 +8 -0
- package/dist/bin/primitive.js.map +1 -1
- package/dist/src/commands/apps.js +54 -2
- package/dist/src/commands/apps.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 +220 -82
- package/dist/src/commands/databases.js.map +1 -1
- package/dist/src/commands/documents.js +77 -0
- package/dist/src/commands/documents.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/settings.d.ts +15 -0
- package/dist/src/commands/settings.js +102 -0
- package/dist/src/commands/settings.js.map +1 -0
- package/dist/src/commands/sync-app-settings.d.ts +105 -0
- package/dist/src/commands/sync-app-settings.js +339 -0
- package/dist/src/commands/sync-app-settings.js.map +1 -0
- package/dist/src/commands/sync.d.ts +204 -9
- package/dist/src/commands/sync.js +1706 -371
- 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 +667 -59
- package/dist/src/commands/workflows.js.map +1 -1
- package/dist/src/lib/api-client.d.ts +122 -18
- package/dist/src/lib/api-client.js +131 -2
- package/dist/src/lib/api-client.js.map +1 -1
- package/dist/src/lib/app-settings-descriptor.d.ts +108 -0
- package/dist/src/lib/app-settings-descriptor.js +250 -0
- package/dist/src/lib/app-settings-descriptor.js.map +1 -0
- 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 +76 -0
- package/dist/src/lib/codegen-shared/generatedFiles.js +144 -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 +21 -65
- package/dist/src/lib/db-codegen/dbGenerator.js +117 -321
- package/dist/src/lib/db-codegen/dbGenerator.js.map +1 -1
- package/dist/src/lib/db-codegen/dbNaming.d.ts +37 -0
- package/dist/src/lib/db-codegen/dbNaming.js +70 -0
- package/dist/src/lib/db-codegen/dbNaming.js.map +1 -1
- package/dist/src/lib/db-codegen/dbTemplates.d.ts +165 -5
- package/dist/src/lib/db-codegen/dbTemplates.js +292 -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/db-codegen/dbTypeIR.d.ts +146 -0
- package/dist/src/lib/db-codegen/dbTypeIR.js +517 -0
- package/dist/src/lib/db-codegen/dbTypeIR.js.map +1 -0
- package/dist/src/lib/db-codegen/generated-operation-def-descriptor.d.ts +101 -0
- package/dist/src/lib/db-codegen/generated-operation-def-descriptor.js +200 -0
- package/dist/src/lib/db-codegen/generated-operation-def-descriptor.js.map +1 -0
- 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/swift-codegen/dbGenerator.d.ts +68 -0
- package/dist/src/lib/swift-codegen/dbGenerator.js +380 -0
- package/dist/src/lib/swift-codegen/dbGenerator.js.map +1 -0
- package/dist/src/lib/swift-codegen/dbSwiftTypes.d.ts +42 -0
- package/dist/src/lib/swift-codegen/dbSwiftTypes.js +100 -0
- package/dist/src/lib/swift-codegen/dbSwiftTypes.js.map +1 -0
- package/dist/src/lib/swift-codegen/generator.d.ts +84 -0
- package/dist/src/lib/swift-codegen/generator.js +178 -0
- package/dist/src/lib/swift-codegen/generator.js.map +1 -0
- package/dist/src/lib/swift-codegen/schemaToSwift.d.ts +72 -0
- package/dist/src/lib/swift-codegen/schemaToSwift.js +644 -0
- package/dist/src/lib/swift-codegen/schemaToSwift.js.map +1 -0
- package/dist/src/lib/swift-codegen/swiftNaming.d.ts +85 -0
- package/dist/src/lib/swift-codegen/swiftNaming.js +198 -0
- package/dist/src/lib/swift-codegen/swiftNaming.js.map +1 -0
- package/dist/src/lib/sync-resource-types.d.ts +225 -0
- package/dist/src/lib/sync-resource-types.js +394 -0
- package/dist/src/lib/sync-resource-types.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 +87 -0
- package/dist/src/lib/workflow-codegen/generator.js +290 -0
- package/dist/src/lib/workflow-codegen/generator.js.map +1 -0
- package/dist/src/lib/workflow-codegen/invokerIR.d.ts +85 -0
- package/dist/src/lib/workflow-codegen/invokerIR.js +71 -0
- package/dist/src/lib/workflow-codegen/invokerIR.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 +7 -3
|
@@ -1,16 +1,24 @@
|
|
|
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 { serializeSection, parseTomlToAppSettings, } from "../lib/app-settings-descriptor.js";
|
|
17
|
+
import { serializeAppSettings, collectAppSettingsPushErrors, } from "./sync-app-settings.js";
|
|
18
|
+
import { applyWorkflowBody } from "../lib/workflow-apply.js";
|
|
19
|
+
import { buildWorkflowPayloadFromToml } from "../lib/workflow-payload.js";
|
|
20
|
+
import { detectLayout, migrateLegacyToV2 } from "../lib/block-layout.js";
|
|
21
|
+
import { ABSENT_FROM_EXPORT, LOCAL_ONLY_ABSENT, LOCAL_ONLY_NEW, SYNC_RESOURCE_TYPES, applyPrune, attemptFetch, classifyLocalOnly, hasUncommittedChanges, planPrune, preserveUnwrittenEntries, ruleSetFileKey, } from "../lib/sync-resource-types.js";
|
|
14
22
|
import { success, error, printApiError, info, warn, keyValue, json, divider, } from "../lib/output.js";
|
|
15
23
|
import { confirmPrompt } from "../lib/confirm-prompt.js";
|
|
16
24
|
import chalk from "chalk";
|
|
@@ -132,30 +140,43 @@ export function shouldPushExpandedFile(parsed, storedHash) {
|
|
|
132
140
|
return computeExpandedContentHash(parsed) !== storedHash;
|
|
133
141
|
}
|
|
134
142
|
/**
|
|
135
|
-
* Model defaults for the
|
|
136
|
-
*
|
|
137
|
-
* `perUserMaxRunning`, `perUserMaxQueued`, `
|
|
138
|
-
* `
|
|
139
|
-
* (`WorkflowDefinition`):
|
|
140
|
-
* `
|
|
143
|
+
* Model defaults for the workflow-level fields that `serializeWorkflow` emits
|
|
144
|
+
* with a concrete server default the *local* file may omit (`sync.ts` workflow
|
|
145
|
+
* serializer): `perUserMaxRunning`, `perUserMaxQueued`, `perAppMaxRunning`,
|
|
146
|
+
* `perAppMaxQueued`, `queueTtlSeconds`, `dequeueOrder`, `requiresClientApply`,
|
|
147
|
+
* `syncCallable`. These mirror `models.yaml` (`WorkflowDefinition`):
|
|
148
|
+
* `perUserMaxRunning=4`, `perUserMaxQueued=100`, `perAppMaxRunning=25`,
|
|
149
|
+
* `perAppMaxQueued=10000`, `queueTtlSeconds=43200`, `dequeueOrder="fifo"`,
|
|
150
|
+
* `requiresClientApply=true`, `syncCallable=false`.
|
|
151
|
+
*
|
|
152
|
+
* `perAppMax*` / `queueTtlSeconds` were added here for #1177: once the pull
|
|
153
|
+
* serializer began emitting them (they carry a non-null model default, so a GET
|
|
154
|
+
* always returns them), an unchanged workflow using the server defaults hashed
|
|
155
|
+
* unequal to a local TOML that omits them — a false `modified` diff that made
|
|
156
|
+
* `sync pull` rewrite the file just to inject defaults. Normalizing both sides
|
|
157
|
+
* to the model default here closes that gap the same way the per-user limits do.
|
|
141
158
|
*
|
|
142
159
|
* This is deliberately NOT a generic models.yaml default importer — the model
|
|
143
|
-
* carries
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
160
|
+
* carries defaults the serializer never writes. Only the serializer-emitted
|
|
161
|
+
* surface with a concrete default matters for a content comparison, because the
|
|
162
|
+
* remote side is hashed via `serializeWorkflow`. Emit-when-set fields whose GET
|
|
163
|
+
* value is null/empty when unset (`accessRule`, `runAs`, `capabilities`) are
|
|
164
|
+
* omitted on both sides and so need no normalization here.
|
|
147
165
|
*/
|
|
148
166
|
const WORKFLOW_SERIALIZER_DEFAULTS = {
|
|
149
167
|
perUserMaxRunning: 4,
|
|
150
168
|
perUserMaxQueued: 100,
|
|
169
|
+
perAppMaxRunning: 25,
|
|
170
|
+
perAppMaxQueued: 10000,
|
|
171
|
+
queueTtlSeconds: 43200,
|
|
151
172
|
dequeueOrder: "fifo",
|
|
152
173
|
requiresClientApply: true,
|
|
153
174
|
syncCallable: false,
|
|
154
175
|
};
|
|
155
176
|
/**
|
|
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.
|
|
177
|
+
* Normalize a parsed workflow TOML object so that any of the
|
|
178
|
+
* serializer-emitted default fields the *local* file omits are filled in with
|
|
179
|
+
* the model default. Used by `diff` so a hand-authored workflow that omits e.g.
|
|
159
180
|
* `perUserMaxRunning` compares equal to a server that defaulted it to 4
|
|
160
181
|
* (the #1175 false-positive guard) — while a server value that was
|
|
161
182
|
* *explicitly set* to a non-default (e.g. 8) still shows as Modified.
|
|
@@ -173,6 +194,25 @@ export function normalizeWorkflowTomlDefaults(parsed) {
|
|
|
173
194
|
workflow[field] = defaultValue;
|
|
174
195
|
}
|
|
175
196
|
}
|
|
197
|
+
// Schema-form canonicalization (issue #1446): a legacy file carries
|
|
198
|
+
// `inputSchema` / `outputSchema` as a JSON string, while the native form (and
|
|
199
|
+
// the remote serialization) carries a parsed object. Parse the string form to
|
|
200
|
+
// its object here so the two forms hash equal — `sync diff` stays
|
|
201
|
+
// form-independent and an unchanged schema never shows a false `modified`
|
|
202
|
+
// during the transition. This runs only on the diff-hash path
|
|
203
|
+
// (`hashWorkflowTomlForDiff`); the stored-state hash path is untouched, so
|
|
204
|
+
// there is no forced one-time re-push after upgrade.
|
|
205
|
+
for (const field of ["inputSchema", "outputSchema"]) {
|
|
206
|
+
if (typeof workflow[field] === "string") {
|
|
207
|
+
try {
|
|
208
|
+
workflow[field] = JSON.parse(workflow[field]);
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
// Not valid JSON — leave the raw string so a genuinely malformed value
|
|
212
|
+
// still compares by its literal content.
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
176
216
|
return { ...parsed, workflow };
|
|
177
217
|
}
|
|
178
218
|
/**
|
|
@@ -191,42 +231,350 @@ export function hashWorkflowTomlForDiff(parsed) {
|
|
|
191
231
|
* `getWorkflow` + active `getWorkflowConfig`), mirroring exactly what a fresh
|
|
192
232
|
* `sync pull` would write to disk. Serializes via `serializeWorkflow`, parses
|
|
193
233
|
* the resulting TOML (remote serialized TOML never carries `include`s, so a
|
|
194
|
-
* plain `
|
|
234
|
+
* plain `parseConfigToml` is sufficient — no fragment path needed), then hashes
|
|
195
235
|
* through `hashWorkflowTomlForDiff` so it lines up with the local-file hash.
|
|
196
236
|
*/
|
|
197
237
|
export function hashRemoteWorkflowForDiff(workflow, draft, configs) {
|
|
198
238
|
const serialized = serializeWorkflow(workflow, draft, configs || []);
|
|
199
|
-
|
|
239
|
+
// serializeWorkflow emits via smol-toml (issue #1450) and may contain a
|
|
240
|
+
// heterogeneous array, so re-parse through the TOML 1.0-compliant facade.
|
|
241
|
+
const parsed = parseConfigToml(serialized);
|
|
200
242
|
return hashWorkflowTomlForDiff(parsed);
|
|
201
243
|
}
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
244
|
+
/**
|
|
245
|
+
* Build the [auth] block of app.toml from server settings (pull direction).
|
|
246
|
+
*
|
|
247
|
+
* Thin wrapper over the shared field descriptor
|
|
248
|
+
* (`cli/src/lib/app-settings-descriptor.ts`), which now owns the [auth] field
|
|
249
|
+
* set that `AUTH_BOOLEAN_KEYS`/`RECOGNIZED_AUTH_KEYS` used to hard-code. The
|
|
250
|
+
* descriptor drives all four sections in one place, so pull, push, and the
|
|
251
|
+
* unrecognized-key warning can never drift apart. `googleClientSecret` is a
|
|
252
|
+
* secret descriptor entry and is never written.
|
|
253
|
+
*/
|
|
254
|
+
export function serializeAuthBlock(settings) {
|
|
255
|
+
return serializeSection(settings, "auth");
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Translate the [auth] block of app.toml into app-settings fields (push
|
|
259
|
+
* direction). Kept as a focused helper over the shared descriptor; the full
|
|
260
|
+
* push path uses `parseTomlToAppSettings` for every section. Only keys present
|
|
261
|
+
* in the TOML are forwarded, so an omitted key never overwrites server state.
|
|
262
|
+
* Descriptor errors (a secret key, a type mismatch) are surfaced alongside the
|
|
263
|
+
* unrecognized-key warnings.
|
|
264
|
+
*/
|
|
265
|
+
export function parseAppAuthSettings(auth) {
|
|
266
|
+
const { settings, warnings, errors } = parseTomlToAppSettings({ auth });
|
|
267
|
+
return { settings, warnings: [...warnings, ...errors] };
|
|
268
|
+
}
|
|
269
|
+
// `serializeAppSettings` (pull) and the app.toml push validation now live in
|
|
270
|
+
// `sync-app-settings.ts`, shared with the `primitive settings` command — see
|
|
271
|
+
// the imports above. Both directions stay descriptor-driven
|
|
272
|
+
// (`cli/src/lib/app-settings-descriptor.ts`).
|
|
273
|
+
// --- Config vars (issue #1423) ---------------------------------------------
|
|
274
|
+
// Per-environment non-secret scalars, round-tripped as a single flat
|
|
275
|
+
// `vars.toml` at the sync-dir root (like app.toml). They bind as
|
|
276
|
+
// `{{ vars.KEY }}` in workflow/integration config and `vars.*` in CEL rules.
|
|
277
|
+
/** Key format shared with the vars service (`^[A-Z][A-Z0-9_]{0,63}$`). */
|
|
278
|
+
const VARS_KEY_RE = /^[A-Z][A-Z0-9_]{0,63}$/;
|
|
279
|
+
/** SHA-256 of a var's value — the per-key content hash tracked in sync state. */
|
|
280
|
+
function hashVarValue(value) {
|
|
281
|
+
return createHash("sha256").update(value).digest("hex");
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Serialize the app config vars to a flat TOML table (keys sorted for a stable,
|
|
285
|
+
* diff-friendly file). Values are non-secret plaintext; a header comment says
|
|
286
|
+
* so loudly. An empty var set yields a comment-only file.
|
|
287
|
+
*/
|
|
288
|
+
function serializeVars(vars) {
|
|
289
|
+
const data = {};
|
|
290
|
+
for (const v of [...vars].sort((a, b) => a.key.localeCompare(b.key))) {
|
|
291
|
+
data[v.key] = String(v.value ?? "");
|
|
219
292
|
}
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
293
|
+
const header = "# Per-environment non-secret config vars (issue #1423).\n" +
|
|
294
|
+
"# Bind as {{ vars.KEY }} in workflow/integration config and vars.* in CEL\n" +
|
|
295
|
+
"# rules. Values are checked into the repo and NOT secret — never put a\n" +
|
|
296
|
+
"# credential here; use `primitive secrets` for that.\n\n";
|
|
297
|
+
return header + stringifyConfigToml(data);
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Decide whether `sync pull` should (over)write `vars.toml`, given the outcome
|
|
301
|
+
* of fetching the app's config vars.
|
|
302
|
+
*
|
|
303
|
+
* The distinction that matters (issue #1423 review): a SUCCESSFUL fetch that
|
|
304
|
+
* returns zero vars is safe to write — the comment-only file is the intended
|
|
305
|
+
* empty state. A FAILED fetch (transient 401/500/network) must NOT write,
|
|
306
|
+
* because `serializeVars([])` would clobber a good local `vars.toml` with a
|
|
307
|
+
* comment-only file while the pull otherwise reports success. On failure we
|
|
308
|
+
* keep the prior manifest entries so the untouched file and the sync state stay
|
|
309
|
+
* consistent.
|
|
310
|
+
*
|
|
311
|
+
* Pure and side-effect-free (the caller owns the actual `writeFileSync`) so the
|
|
312
|
+
* clobber guard is unit-testable without a live server.
|
|
313
|
+
*/
|
|
314
|
+
export function planVarsPull(outcome, priorVars) {
|
|
315
|
+
const varEntities = {};
|
|
316
|
+
if (!outcome.ok) {
|
|
317
|
+
// Preserve the prior manifest entries for the (untouched) local file.
|
|
318
|
+
if (priorVars)
|
|
319
|
+
Object.assign(varEntities, priorVars);
|
|
320
|
+
return { write: false, content: null, varEntities };
|
|
321
|
+
}
|
|
322
|
+
for (const v of outcome.vars) {
|
|
323
|
+
varEntities[v.key] = {
|
|
324
|
+
// The vars API returns `updatedAt` (see toVarResponse); record it as the
|
|
325
|
+
// baseline server timestamp so `sync push` can detect a concurrent remote
|
|
326
|
+
// edit. Fall back to `modifiedAt`/now only for older/partial responses.
|
|
327
|
+
modifiedAt: v.updatedAt || v.modifiedAt || new Date().toISOString(),
|
|
328
|
+
contentHash: hashVarValue(String(v.value ?? "")),
|
|
227
329
|
};
|
|
228
330
|
}
|
|
229
|
-
return
|
|
331
|
+
return { write: true, content: serializeVars(outcome.vars), varEntities };
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* Maximum var value size, in bytes — mirrors the server's `MAX_VALUE_BYTES`
|
|
335
|
+
* (app-secrets-service.ts). Kept in sync manually because the CLI can't import
|
|
336
|
+
* the Worker service module.
|
|
337
|
+
*/
|
|
338
|
+
const MAX_VAR_VALUE_BYTES = 2048;
|
|
339
|
+
/**
|
|
340
|
+
* Maximum number of vars per app — mirrors the server's `MAX_VARS_PER_APP`
|
|
341
|
+
* (app-config-vars-service.ts). Kept in sync manually because the CLI can't
|
|
342
|
+
* import the Worker service module.
|
|
343
|
+
*/
|
|
344
|
+
const MAX_VARS_PER_APP = 100;
|
|
345
|
+
/**
|
|
346
|
+
* Validate a parsed `vars.toml` table against the same key/value constraints
|
|
347
|
+
* the server enforces (key format, string type, non-empty, size cap) plus the
|
|
348
|
+
* aggregate per-app var-count cap. Returns a list of human-readable errors
|
|
349
|
+
* (empty when valid).
|
|
350
|
+
*
|
|
351
|
+
* Run in `sync push`'s up-front preflight pass (issue #1423 review) so an
|
|
352
|
+
* invalid entry — or a file that would exceed the server's `MAX_VARS_PER_APP`
|
|
353
|
+
* cap — aborts BEFORE any mutation is applied. Validating only individual
|
|
354
|
+
* entries let a 101-entry file create/update many vars before the server
|
|
355
|
+
* rejected a later create at the cap, leaving a partial push (issue #1423
|
|
356
|
+
* review pass 3). Pure and side-effect-free so it's unit-testable without a
|
|
357
|
+
* live server.
|
|
358
|
+
*/
|
|
359
|
+
export function validateVarsFile(parsedVars) {
|
|
360
|
+
const errors = [];
|
|
361
|
+
const keyCount = Object.keys(parsedVars).length;
|
|
362
|
+
if (keyCount > MAX_VARS_PER_APP) {
|
|
363
|
+
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"}.`);
|
|
364
|
+
}
|
|
365
|
+
for (const [key, rawValue] of Object.entries(parsedVars)) {
|
|
366
|
+
if (!VARS_KEY_RE.test(key)) {
|
|
367
|
+
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)`);
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
if (typeof rawValue !== "string") {
|
|
371
|
+
errors.push(`Var "${key}" in vars.toml must be a string value (got ${typeof rawValue}) — quote the value.`);
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
const byteLength = new TextEncoder().encode(rawValue).length;
|
|
375
|
+
if (byteLength === 0) {
|
|
376
|
+
errors.push(`Var "${key}" in vars.toml must not be empty.`);
|
|
377
|
+
}
|
|
378
|
+
else if (byteLength > MAX_VAR_VALUE_BYTES) {
|
|
379
|
+
errors.push(`Var "${key}" in vars.toml exceeds the maximum value size of ${MAX_VAR_VALUE_BYTES} bytes (got ${byteLength}).`);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
return errors;
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* Plan the config-var writes a `sync push` should make, detecting concurrent
|
|
386
|
+
* remote edits before overwriting them (issue #1423 review).
|
|
387
|
+
*
|
|
388
|
+
* This is the client-side half of a two-layer concurrency guard. The vars
|
|
389
|
+
* PUT/DELETE endpoints now honor an `expectedModifiedAt` precondition (issue
|
|
390
|
+
* #1423 review r-2 P1) and raise a `ConflictError` when the server row changed
|
|
391
|
+
* since the caller's snapshot — the atomic, server-side check every other
|
|
392
|
+
* synced entity relies on. This function is the fast fail-before-mutate layer:
|
|
393
|
+
* it compares the recorded baseline against the current remote snapshot and, for
|
|
394
|
+
* a var whose remote value has drifted from what we last synced, reports a
|
|
395
|
+
* conflict (fed into the same `conflicts[]` accumulator the other entities use)
|
|
396
|
+
* before any write is attempted. A var tracked at the baseline but absent from
|
|
397
|
+
* the remote snapshot (deleted remotely while edited locally) is remote drift
|
|
398
|
+
* too, and also reported as a conflict rather than silently recreated.
|
|
399
|
+
* Conversely, a var removed locally that is already absent remotely is NOT
|
|
400
|
+
* planned for deletion — its desired (absent) state is already met, so a DELETE
|
|
401
|
+
* would 404 and fail the push. The same drift check guards deletions: a var
|
|
402
|
+
* removed locally whose remote value no longer matches the baseline was edited
|
|
403
|
+
* remotely since the last sync, and deleting it would silently discard that
|
|
404
|
+
* edit — reported as a conflict instead. `force` skips the conflict guard,
|
|
405
|
+
* matching `sync push --force` for every other entity.
|
|
406
|
+
*
|
|
407
|
+
* Pure and side-effect-free (the caller owns the API calls and sync-state
|
|
408
|
+
* updates) so the guard is unit-testable without a live server. Assumes
|
|
409
|
+
* `parsedVars` has already passed `validateVarsFile` (values are strings). The
|
|
410
|
+
* caller normally fails the push closed when the remote snapshot can't be
|
|
411
|
+
* fetched, but under `--force` it falls back to an empty snapshot and sets
|
|
412
|
+
* `snapshotUnavailable` — which keeps removed-key deletions in the plan (they'd
|
|
413
|
+
* otherwise be mistaken for already-deleted and dropped) and leans on the apply
|
|
414
|
+
* loop's idempotent 404 handling (issue #1423 review r-2 P2).
|
|
415
|
+
*/
|
|
416
|
+
export function planVarsPush(parsedVars, baseline, remoteVars, options = {}) {
|
|
417
|
+
const remoteByKey = new Map(remoteVars.map((r) => [r.key, r]));
|
|
418
|
+
const upserts = [];
|
|
419
|
+
const conflicts = [];
|
|
420
|
+
const deletions = [];
|
|
421
|
+
let skippedCount = 0;
|
|
422
|
+
const localKeys = new Set();
|
|
423
|
+
for (const [key, value] of Object.entries(parsedVars)) {
|
|
424
|
+
localKeys.add(key);
|
|
425
|
+
const valueHash = hashVarValue(value);
|
|
426
|
+
const existing = baseline?.[key];
|
|
427
|
+
// Unchanged since last sync — skip (unless forced), mirroring the
|
|
428
|
+
// content-hash skip the other file-backed entities use.
|
|
429
|
+
if (!options.force && existing && existing.contentHash === valueHash) {
|
|
430
|
+
skippedCount++;
|
|
431
|
+
continue;
|
|
432
|
+
}
|
|
433
|
+
const action = existing ? "update" : "create";
|
|
434
|
+
// Concurrent-edit guard: if the remote value has drifted from the baseline
|
|
435
|
+
// we last synced (for an update) — or a var with this key was created
|
|
436
|
+
// remotely with a different value (for a create) — pushing would silently
|
|
437
|
+
// clobber that remote change. Report a conflict instead. `--force`
|
|
438
|
+
// overrides, exactly like the other entities.
|
|
439
|
+
if (!options.force) {
|
|
440
|
+
const remote = remoteByKey.get(key);
|
|
441
|
+
if (remote && typeof remote.value === "string") {
|
|
442
|
+
const remoteHash = hashVarValue(remote.value);
|
|
443
|
+
const remoteDrifted = existing?.contentHash
|
|
444
|
+
? remoteHash !== existing.contentHash
|
|
445
|
+
: remoteHash !== valueHash;
|
|
446
|
+
if (remoteDrifted) {
|
|
447
|
+
conflicts.push({
|
|
448
|
+
key,
|
|
449
|
+
serverModifiedAt: remote.updatedAt || "unknown",
|
|
450
|
+
localModifiedAt: existing?.modifiedAt || "unknown",
|
|
451
|
+
});
|
|
452
|
+
continue;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
else if (existing) {
|
|
456
|
+
// Tracked at the baseline but absent from the remote snapshot: the var
|
|
457
|
+
// was deleted remotely since our last sync while we edited it locally
|
|
458
|
+
// (we only reach here past the unchanged-skip above, so the local value
|
|
459
|
+
// differs from the baseline). Upserting would recreate the
|
|
460
|
+
// remotely-deleted var — that missing remote value is remote drift too,
|
|
461
|
+
// so report a conflict instead (issue #1423 review pass 3). `--force`
|
|
462
|
+
// overrides.
|
|
463
|
+
conflicts.push({
|
|
464
|
+
key,
|
|
465
|
+
serverModifiedAt: "deleted",
|
|
466
|
+
localModifiedAt: existing.modifiedAt || "unknown",
|
|
467
|
+
});
|
|
468
|
+
continue;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
upserts.push({ action, key, value, valueHash });
|
|
472
|
+
}
|
|
473
|
+
// Prune vars tracked in the baseline but removed from the local file
|
|
474
|
+
// (hard-delete; nested-entity precedent). Skip keys already absent from the
|
|
475
|
+
// remote snapshot: a var independently deleted remotely and also removed
|
|
476
|
+
// locally is already in its desired (absent) state, and a server DELETE would
|
|
477
|
+
// 404 and fail the whole push (issue #1423 review pass 3). `--force` does not
|
|
478
|
+
// resurrect these deletes — the desired state is met either way.
|
|
479
|
+
for (const [key, entry] of Object.entries(baseline ?? {})) {
|
|
480
|
+
if (localKeys.has(key))
|
|
481
|
+
continue;
|
|
482
|
+
const remote = remoteByKey.get(key);
|
|
483
|
+
if (!remote) {
|
|
484
|
+
// Normally an absent remote entry means the var is already gone, so its
|
|
485
|
+
// desired (deleted) state is met and a DELETE would 404 — skip it. But
|
|
486
|
+
// when the remote snapshot could NOT be fetched (the `--force` fallback
|
|
487
|
+
// after a failed `listAppConfigVars`, which passes an empty snapshot),
|
|
488
|
+
// "absent from the snapshot" means "unknown", not "gone". Suppressing the
|
|
489
|
+
// delete would silently keep a var the user removed from `vars.toml` while
|
|
490
|
+
// the push still reports success (issue #1423 review r-2 P2). Enqueue the
|
|
491
|
+
// removal and rely on the apply loop's idempotent 404 handling to absorb a
|
|
492
|
+
// key that really was already absent.
|
|
493
|
+
if (options.snapshotUnavailable)
|
|
494
|
+
deletions.push(key);
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
497
|
+
// Concurrent-edit guard, mirroring the upsert path (issue #1423 review
|
|
498
|
+
// pass 4): deleting a var whose remote value drifted from the last-synced
|
|
499
|
+
// baseline would silently discard that remote edit. When drift cannot be
|
|
500
|
+
// verified (no baseline hash, or no remote value in the snapshot), fail
|
|
501
|
+
// closed and report the conflict. `--force` deletes unconditionally.
|
|
502
|
+
if (!options.force) {
|
|
503
|
+
const remoteDrifted = typeof remote.value !== "string" ||
|
|
504
|
+
!entry.contentHash ||
|
|
505
|
+
hashVarValue(remote.value) !== entry.contentHash;
|
|
506
|
+
if (remoteDrifted) {
|
|
507
|
+
conflicts.push({
|
|
508
|
+
key,
|
|
509
|
+
serverModifiedAt: remote.updatedAt || "unknown",
|
|
510
|
+
localModifiedAt: entry.modifiedAt || "unknown",
|
|
511
|
+
});
|
|
512
|
+
continue;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
deletions.push(key);
|
|
516
|
+
}
|
|
517
|
+
return { upserts, deletions, conflicts, skippedCount };
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* Compute the number of config vars the server would hold after a push plan
|
|
521
|
+
* is applied (issue #1423 review pass 5). Validating only the local file's
|
|
522
|
+
* entry count misses remote-only vars: 95 local entries plus 10 vars that
|
|
523
|
+
* exist only on the server passes a local-count check, and the creates then
|
|
524
|
+
* exceed the server's cap mid-push, after earlier writes were already
|
|
525
|
+
* applied. Counted from the fetched remote snapshot: planned deletions remove
|
|
526
|
+
* keys that exist remotely (the plan already omits deletes for absent keys),
|
|
527
|
+
* and only upserts whose key is NOT already on the server add to the total.
|
|
528
|
+
* Conflicted keys are not mutated, so they contribute nothing beyond their
|
|
529
|
+
* current remote presence. Pure and side-effect-free so it's unit-testable
|
|
530
|
+
* without a live server.
|
|
531
|
+
*/
|
|
532
|
+
export function countVarsAfterPush(remoteVars, plan) {
|
|
533
|
+
const remoteKeys = new Set(remoteVars.map((r) => r.key));
|
|
534
|
+
let count = remoteKeys.size;
|
|
535
|
+
for (const key of plan.deletions) {
|
|
536
|
+
if (remoteKeys.has(key))
|
|
537
|
+
count--;
|
|
538
|
+
}
|
|
539
|
+
for (const upsert of plan.upserts) {
|
|
540
|
+
if (!remoteKeys.has(upsert.key))
|
|
541
|
+
count++;
|
|
542
|
+
}
|
|
543
|
+
return count;
|
|
544
|
+
}
|
|
545
|
+
/**
|
|
546
|
+
* Compute the `sync diff` rows for config vars (issue #1423 review). Before
|
|
547
|
+
* this, `sync diff` ignored vars entirely, so an add/remove/value-drift between
|
|
548
|
+
* the local `vars.toml` and the server read as no difference. Value-aware: a
|
|
549
|
+
* var present on both sides whose value differs is reported as `modified`
|
|
550
|
+
* (framed like the other content-aware entities — `sync pull` would rewrite the
|
|
551
|
+
* local value). Pure and side-effect-free so it's unit-testable without a live
|
|
552
|
+
* server.
|
|
553
|
+
*/
|
|
554
|
+
export function diffVars(localVars, remoteVars) {
|
|
555
|
+
const rows = [];
|
|
556
|
+
for (const [key, localValue] of localVars) {
|
|
557
|
+
if (!remoteVars.has(key)) {
|
|
558
|
+
rows.push({ type: "var", key, status: "local only" });
|
|
559
|
+
}
|
|
560
|
+
else if (remoteVars.get(key) !== localValue) {
|
|
561
|
+
rows.push({
|
|
562
|
+
type: "var",
|
|
563
|
+
key,
|
|
564
|
+
status: "modified",
|
|
565
|
+
hint: "run `sync pull` to rewrite vars.toml to match the server value",
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
else {
|
|
569
|
+
rows.push({ type: "var", key, status: "exists" });
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
for (const key of remoteVars.keys()) {
|
|
573
|
+
if (!localVars.has(key)) {
|
|
574
|
+
rows.push({ type: "var", key, status: "remote only" });
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
return rows;
|
|
230
578
|
}
|
|
231
579
|
function serializeIntegration(integration) {
|
|
232
580
|
const data = {
|
|
@@ -239,9 +587,9 @@ function serializeIntegration(integration) {
|
|
|
239
587
|
},
|
|
240
588
|
requestConfig: integration.requestConfig || {},
|
|
241
589
|
};
|
|
242
|
-
return
|
|
590
|
+
return stringifyConfigToml(data);
|
|
243
591
|
}
|
|
244
|
-
function serializeWebhook(webhook) {
|
|
592
|
+
export function serializeWebhook(webhook) {
|
|
245
593
|
const data = {
|
|
246
594
|
webhook: {
|
|
247
595
|
key: webhook.webhookKey,
|
|
@@ -256,6 +604,17 @@ function serializeWebhook(webhook) {
|
|
|
256
604
|
secretGracePeriodMs: webhook.secretGracePeriodMs,
|
|
257
605
|
},
|
|
258
606
|
};
|
|
607
|
+
// Re-emit a `{{secrets.KEY}}` reference so a pull→push round-trip is lossless
|
|
608
|
+
// (#1235). The server only returns `signingSecret` when it is such a
|
|
609
|
+
// reference (a real secret is returned masked, never in plaintext); guard on
|
|
610
|
+
// the template shape here too so a masked or raw value can never be written
|
|
611
|
+
// to TOML.
|
|
612
|
+
if (typeof webhook.signingSecret === "string" &&
|
|
613
|
+
// Whitespace-tolerant (#1468) to match the server's shared resolver, so a
|
|
614
|
+
// hand-authored spaced reference `{{ secrets.KEY }}` round-trips pull→push.
|
|
615
|
+
/\{\{\s*secrets\.[A-Z][A-Z0-9_]{0,63}\s*\}\}/.test(webhook.signingSecret)) {
|
|
616
|
+
data.webhook.signingSecret = webhook.signingSecret;
|
|
617
|
+
}
|
|
259
618
|
// Parse JSON fields if they're strings
|
|
260
619
|
if (webhook.allowedIpCidrs) {
|
|
261
620
|
try {
|
|
@@ -284,7 +643,7 @@ function serializeWebhook(webhook) {
|
|
|
284
643
|
if (data.webhook[k] === undefined)
|
|
285
644
|
delete data.webhook[k];
|
|
286
645
|
});
|
|
287
|
-
return
|
|
646
|
+
return stringifyConfigToml(data);
|
|
288
647
|
}
|
|
289
648
|
function serializeCronTrigger(trigger) {
|
|
290
649
|
const data = {
|
|
@@ -320,7 +679,7 @@ function serializeCronTrigger(trigger) {
|
|
|
320
679
|
if (data.cronTrigger[k] === undefined)
|
|
321
680
|
delete data.cronTrigger[k];
|
|
322
681
|
});
|
|
323
|
-
return
|
|
682
|
+
return stringifyConfigToml(data);
|
|
324
683
|
}
|
|
325
684
|
function serializeBlobBucket(bucket) {
|
|
326
685
|
const data = {
|
|
@@ -346,7 +705,32 @@ function serializeBlobBucket(bucket) {
|
|
|
346
705
|
if (data.bucket[k] === undefined)
|
|
347
706
|
delete data.bucket[k];
|
|
348
707
|
});
|
|
349
|
-
return
|
|
708
|
+
return stringifyConfigToml(data);
|
|
709
|
+
}
|
|
710
|
+
/**
|
|
711
|
+
* Build the PATCH payload for a blob-bucket update from local TOML.
|
|
712
|
+
*
|
|
713
|
+
* EXACT extraction of the inline builder shared by the existing-update and
|
|
714
|
+
* 409-adopt branches — do NOT "clean up" the truthiness checks. The server
|
|
715
|
+
* treats `preset` and `accessPolicy` as mutually exclusive, only clears
|
|
716
|
+
* `ruleSetId` when a preset/accessPolicy is also present, and nulls
|
|
717
|
+
* `description` only when the key is explicitly present with a falsy value.
|
|
718
|
+
* `bucketKey` and `ttlTier` are immutable and never sent. Both call sites
|
|
719
|
+
* must go through this helper so their field sets never drift.
|
|
720
|
+
*/
|
|
721
|
+
function buildBlobBucketUpdatePayload(bucket) {
|
|
722
|
+
const updatePayload = {};
|
|
723
|
+
if (bucket.preset)
|
|
724
|
+
updatePayload.preset = bucket.preset;
|
|
725
|
+
else if (bucket.accessPolicy)
|
|
726
|
+
updatePayload.accessPolicy = bucket.accessPolicy;
|
|
727
|
+
if (bucket.ruleSetId)
|
|
728
|
+
updatePayload.ruleSetId = bucket.ruleSetId;
|
|
729
|
+
if (bucket.name)
|
|
730
|
+
updatePayload.name = bucket.name;
|
|
731
|
+
if (bucket.description !== undefined)
|
|
732
|
+
updatePayload.description = bucket.description || null;
|
|
733
|
+
return updatePayload;
|
|
350
734
|
}
|
|
351
735
|
function serializePrompt(prompt) {
|
|
352
736
|
const data = {
|
|
@@ -369,9 +753,102 @@ function serializePrompt(prompt) {
|
|
|
369
753
|
userPromptTemplate: config.userPromptTemplate,
|
|
370
754
|
})),
|
|
371
755
|
};
|
|
372
|
-
return
|
|
756
|
+
return stringifyConfigToml(data);
|
|
373
757
|
}
|
|
374
|
-
|
|
758
|
+
/**
|
|
759
|
+
* Emit a workflow schema field (`inputSchema` / `outputSchema`) for the TOML
|
|
760
|
+
* `[workflow]` table.
|
|
761
|
+
*
|
|
762
|
+
* Issue #1446: pull used to `JSON.stringify` both schema fields into a single
|
|
763
|
+
* escaped-JSON line — unreadable and undiffable. We now emit the parsed JSON
|
|
764
|
+
* Schema as a native `[workflow.inputSchema]` / `[workflow.outputSchema]`
|
|
765
|
+
* table whenever it round-trips faithfully (`canEmitNative === null`), falling
|
|
766
|
+
* back to the JSON string only for the values TOML silently drops (`null` /
|
|
767
|
+
* `undefined` anywhere in the schema). The empty-object schema `{}` is a real,
|
|
768
|
+
* distinct value ("accepts anything") and emits as an empty table.
|
|
769
|
+
*
|
|
770
|
+
* Returns `undefined` for an absent schema (the key is then omitted). The
|
|
771
|
+
* sponsor-approved decision is to always move to native aggressively — no
|
|
772
|
+
* form preservation — so this does not consult the existing file's form.
|
|
773
|
+
*/
|
|
774
|
+
function serializeWorkflowSchemaField(value, fieldName, workflowKey, log) {
|
|
775
|
+
if (value === undefined || value === null)
|
|
776
|
+
return undefined;
|
|
777
|
+
// Schemas arrive from the server GET as parsed objects; defensively accept a
|
|
778
|
+
// JSON string too (e.g. an inline-authored file re-serialized here).
|
|
779
|
+
let obj = value;
|
|
780
|
+
if (typeof value === "string") {
|
|
781
|
+
try {
|
|
782
|
+
obj = JSON.parse(value);
|
|
783
|
+
}
|
|
784
|
+
catch {
|
|
785
|
+
// Not valid JSON — keep the original string as the emitted value.
|
|
786
|
+
return value;
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
if (obj && typeof obj === "object") {
|
|
790
|
+
const reason = canEmitNative(obj);
|
|
791
|
+
if (reason) {
|
|
792
|
+
log(`Workflow '${workflowKey}': cannot emit \`${fieldName}\` as native TOML (${reason}); using JSON string.`);
|
|
793
|
+
return JSON.stringify(obj);
|
|
794
|
+
}
|
|
795
|
+
return obj;
|
|
796
|
+
}
|
|
797
|
+
// A non-object schema (unexpected) — preserve it as a JSON string.
|
|
798
|
+
return JSON.stringify(obj);
|
|
799
|
+
}
|
|
800
|
+
/**
|
|
801
|
+
* Rewrite a workflow TOML file's string-form `inputSchema` / `outputSchema` to
|
|
802
|
+
* native object form for `sync migrate-toml` (issue #1446).
|
|
803
|
+
*
|
|
804
|
+
* Purely local, no server fetch. Parses the raw source WITHOUT expanding
|
|
805
|
+
* fragment `include`s (so those directives survive the rewrite), swaps a
|
|
806
|
+
* JSON-string schema for its parsed object when it round-trips faithfully
|
|
807
|
+
* (`canEmitNative === null`), and re-serializes via `stringifyConfigToml`.
|
|
808
|
+
*
|
|
809
|
+
* Only rewrites when a field is actually converted: a file whose schemas are
|
|
810
|
+
* already native, absent, or un-representable (a `null` default) is returned
|
|
811
|
+
* with `changed: false` and its original bytes untouched — so migrate is
|
|
812
|
+
* idempotent and never reports a spurious migration or reformats a file it
|
|
813
|
+
* didn't need to touch.
|
|
814
|
+
*/
|
|
815
|
+
export function rewriteWorkflowSchemasToNative(rawToml, workflowKey = "", log = () => { }) {
|
|
816
|
+
const parsed = parseConfigToml(rawToml);
|
|
817
|
+
if (!parsed || typeof parsed !== "object" || !parsed.workflow) {
|
|
818
|
+
return { content: rawToml, changed: false };
|
|
819
|
+
}
|
|
820
|
+
const workflow = parsed.workflow;
|
|
821
|
+
let converted = false;
|
|
822
|
+
for (const field of ["inputSchema", "outputSchema"]) {
|
|
823
|
+
const value = workflow[field];
|
|
824
|
+
// Only a JSON-string schema is a migration candidate; an object is already
|
|
825
|
+
// native and an absent key needs nothing.
|
|
826
|
+
if (typeof value !== "string")
|
|
827
|
+
continue;
|
|
828
|
+
let obj;
|
|
829
|
+
try {
|
|
830
|
+
obj = JSON.parse(value);
|
|
831
|
+
}
|
|
832
|
+
catch {
|
|
833
|
+
// Not JSON — leave the literal string in place.
|
|
834
|
+
continue;
|
|
835
|
+
}
|
|
836
|
+
if (!obj || typeof obj !== "object")
|
|
837
|
+
continue;
|
|
838
|
+
const reason = canEmitNative(obj);
|
|
839
|
+
if (reason) {
|
|
840
|
+
log(`Workflow '${workflowKey}': cannot migrate \`${field}\` to native TOML (${reason}); leaving as JSON string.`);
|
|
841
|
+
continue;
|
|
842
|
+
}
|
|
843
|
+
workflow[field] = obj;
|
|
844
|
+
converted = true;
|
|
845
|
+
}
|
|
846
|
+
if (!converted)
|
|
847
|
+
return { content: rawToml, changed: false };
|
|
848
|
+
return { content: stringifyConfigToml(parsed), changed: true };
|
|
849
|
+
}
|
|
850
|
+
export function serializeWorkflow(workflow, draft, configs, logger) {
|
|
851
|
+
const log = logger ?? (() => { });
|
|
375
852
|
// Find the active config or use the first one
|
|
376
853
|
const activeConfigId = workflow.activeConfigId;
|
|
377
854
|
const activeConfig = configs?.find((c) => c.configId === activeConfigId) || configs?.[0];
|
|
@@ -391,8 +868,24 @@ function serializeWorkflow(workflow, draft, configs) {
|
|
|
391
868
|
// #1081 — workflow principal mode. Emit only when set so a fresh
|
|
392
869
|
// pull → push round-trips it without writing a noisy `runAs = ""`.
|
|
393
870
|
runAs: workflow.runAs || undefined,
|
|
871
|
+
// #1233 — opt-in sensitive capability grants. The GET response always
|
|
872
|
+
// returns an array (empty when unset); emit only when non-empty so a
|
|
873
|
+
// fresh pull → push round-trips a real value without writing a noisy
|
|
874
|
+
// `capabilities = []`. This is the load-bearing fix: before this, pull
|
|
875
|
+
// dropped capabilities entirely and a pull → push cycle silently cleared
|
|
876
|
+
// them on the server.
|
|
877
|
+
capabilities: Array.isArray(workflow.capabilities) && workflow.capabilities.length > 0
|
|
878
|
+
? workflow.capabilities
|
|
879
|
+
: undefined,
|
|
394
880
|
perUserMaxRunning: workflow.perUserMaxRunning,
|
|
395
881
|
perUserMaxQueued: workflow.perUserMaxQueued,
|
|
882
|
+
// #1177 — app-level queue limits + queue TTL. Emit only when set so a
|
|
883
|
+
// fresh pull → push round-trips them without writing noisy zero/empty
|
|
884
|
+
// keys. The shared payload builder forwards these on create AND update,
|
|
885
|
+
// so the pull serializer must surface them for the round-trip to close.
|
|
886
|
+
perAppMaxRunning: workflow.perAppMaxRunning ?? undefined,
|
|
887
|
+
perAppMaxQueued: workflow.perAppMaxQueued ?? undefined,
|
|
888
|
+
queueTtlSeconds: workflow.queueTtlSeconds ?? undefined,
|
|
396
889
|
dequeueOrder: workflow.dequeueOrder,
|
|
397
890
|
// Sync-callable flags (#807): always emit both so a fresh pull → push
|
|
398
891
|
// cycle round-trips them. The GET response normalizes these to booleans
|
|
@@ -400,9 +893,11 @@ function serializeWorkflow(workflow, draft, configs) {
|
|
|
400
893
|
// they're safe to coerce here.
|
|
401
894
|
requiresClientApply: workflow.requiresClientApply !== false,
|
|
402
895
|
syncCallable: workflow.syncCallable === true,
|
|
403
|
-
// Schemas are at workflow level
|
|
404
|
-
|
|
405
|
-
|
|
896
|
+
// Schemas are at workflow level. Emit native `[workflow.inputSchema]` /
|
|
897
|
+
// `[workflow.outputSchema]` tables when representable, JSON string only
|
|
898
|
+
// as a data-loss fallback (issue #1446).
|
|
899
|
+
inputSchema: serializeWorkflowSchemaField(workflow.inputSchema, "inputSchema", workflow.workflowKey, log),
|
|
900
|
+
outputSchema: serializeWorkflowSchemaField(workflow.outputSchema, "outputSchema", workflow.workflowKey, log),
|
|
406
901
|
},
|
|
407
902
|
// Use active config steps, fall back to draft
|
|
408
903
|
steps,
|
|
@@ -413,8 +908,12 @@ function serializeWorkflow(workflow, draft, configs) {
|
|
|
413
908
|
status: config.status,
|
|
414
909
|
isActive: config.configId === activeConfigId,
|
|
415
910
|
})) || [],
|
|
911
|
+
// #1304 (P-C): the declared-access manifest — top-level `[metadata]` /
|
|
912
|
+
// `secrets`, merged the same way the collection/database type configs do so
|
|
913
|
+
// a workflow's `md`/`secrets` declarations round-trip through pull → push.
|
|
914
|
+
...serializeDeclaredAccessManifest(workflow.metadataManifest),
|
|
416
915
|
};
|
|
417
|
-
return
|
|
916
|
+
return stringifyConfigToml(data);
|
|
418
917
|
}
|
|
419
918
|
// Database config serialization helpers
|
|
420
919
|
//
|
|
@@ -423,15 +922,17 @@ function serializeWorkflow(workflow, draft, configs) {
|
|
|
423
922
|
// rows over JSON-strings stuffed into a single field. The form is sticky
|
|
424
923
|
// per file — if the existing file uses the legacy JSON-string form, we
|
|
425
924
|
// preserve it so we don't generate surprise diffs. New files default to
|
|
426
|
-
// native.
|
|
427
|
-
//
|
|
925
|
+
// native. The round-trip serializes with `smol-toml` (TOML 1.0), so
|
|
926
|
+
// heterogeneous arrays emit natively (issue #1450); only shapes that TOML
|
|
927
|
+
// genuinely can't represent without data loss (null / undefined / an array
|
|
928
|
+
// containing null) fall back to a JSON string per field with a log message.
|
|
428
929
|
//
|
|
429
930
|
// Issue #666: when the type has a stored `schema` (raw TOML of `[models.*]`
|
|
430
931
|
// blocks), `buildDatabaseTypeTomlData` parses + merges it into the same TOML
|
|
431
932
|
// object so the result round-trips as `[models.<Name>.fields.<field>]`.
|
|
432
933
|
function serializeDatabaseType(typeConfig, operations, ruleSetIdToName, options = {}) {
|
|
433
934
|
const data = buildDatabaseTypeTomlData(typeConfig, operations, ruleSetIdToName, options);
|
|
434
|
-
return
|
|
935
|
+
return stringifyConfigToml(data);
|
|
435
936
|
}
|
|
436
937
|
function serializeEmailTemplate(template) {
|
|
437
938
|
// The API returns { emailType, hasOverride, override: { subject, htmlBody, textBody }, default: { ... } }
|
|
@@ -449,7 +950,7 @@ function serializeEmailTemplate(template) {
|
|
|
449
950
|
if (override.textBody) {
|
|
450
951
|
data.template.textBody = override.textBody;
|
|
451
952
|
}
|
|
452
|
-
return
|
|
953
|
+
return stringifyConfigToml(data);
|
|
453
954
|
}
|
|
454
955
|
function serializeRuleSet(ruleSet) {
|
|
455
956
|
const data = {
|
|
@@ -466,9 +967,9 @@ function serializeRuleSet(ruleSet) {
|
|
|
466
967
|
if (Object.keys(rules).length > 0) {
|
|
467
968
|
data.rules = rules;
|
|
468
969
|
}
|
|
469
|
-
return
|
|
970
|
+
return stringifyConfigToml(data);
|
|
470
971
|
}
|
|
471
|
-
function serializeGroupTypeConfig(config, ruleSetIdToName) {
|
|
972
|
+
export function serializeGroupTypeConfig(config, ruleSetIdToName) {
|
|
472
973
|
const ruleSetName = config.ruleSetId ? (ruleSetIdToName.get(config.ruleSetId) || "") : "";
|
|
473
974
|
const data = {
|
|
474
975
|
groupTypeConfig: {
|
|
@@ -476,8 +977,12 @@ function serializeGroupTypeConfig(config, ruleSetIdToName) {
|
|
|
476
977
|
ruleSetName: ruleSetName,
|
|
477
978
|
autoAddCreator: config.autoAddCreator ?? true,
|
|
478
979
|
},
|
|
980
|
+
// Declared-access manifest (issue #1350, extending #1304 P-C): merge the
|
|
981
|
+
// top-level `[metadata]` + `secrets` fragments so a pull→push→pull cycle
|
|
982
|
+
// round-trips, exactly like the collection/database/workflow type configs.
|
|
983
|
+
...serializeDeclaredAccessManifest(config.metadataManifest),
|
|
479
984
|
};
|
|
480
|
-
return
|
|
985
|
+
return stringifyConfigToml(data);
|
|
481
986
|
}
|
|
482
987
|
function serializeCollectionTypeConfig(config, ruleSetIdToName) {
|
|
483
988
|
const ruleSetName = config.ruleSetId ? (ruleSetIdToName.get(config.ruleSetId) || "") : "";
|
|
@@ -486,8 +991,11 @@ function serializeCollectionTypeConfig(config, ruleSetIdToName) {
|
|
|
486
991
|
collectionType: config.collectionType,
|
|
487
992
|
ruleSetName: ruleSetName,
|
|
488
993
|
},
|
|
994
|
+
// Declared-access manifest (issue #1304, P-C): merge the top-level
|
|
995
|
+
// `[metadata]` + `secrets` fragments so a pull→push→pull cycle round-trips.
|
|
996
|
+
...serializeDeclaredAccessManifest(config.metadataManifest),
|
|
489
997
|
};
|
|
490
|
-
return
|
|
998
|
+
return stringifyConfigToml(data);
|
|
491
999
|
}
|
|
492
1000
|
export function parseDatabaseTypeToml(tomlData) {
|
|
493
1001
|
const typeSection = tomlData.type || {};
|
|
@@ -530,6 +1038,17 @@ export function parseDatabaseTypeToml(tomlData) {
|
|
|
530
1038
|
if (tomlData.triggers) {
|
|
531
1039
|
typeConfig.triggers = tomlData.triggers;
|
|
532
1040
|
}
|
|
1041
|
+
// Declared-access manifest (issue #1304, P-C): the top-level `[metadata]` +
|
|
1042
|
+
// `secrets` blocks. Validated locally before push; the server re-validates
|
|
1043
|
+
// (graph/schema) at persist time.
|
|
1044
|
+
const manifest = parseDeclaredAccessManifestToml(tomlData);
|
|
1045
|
+
if (manifest) {
|
|
1046
|
+
const manifestError = validateDeclaredAccessManifest(manifest);
|
|
1047
|
+
if (manifestError) {
|
|
1048
|
+
throw new Error(`Invalid metadata manifest in database type "${typeSection.databaseType || ""}": ${manifestError}`);
|
|
1049
|
+
}
|
|
1050
|
+
typeConfig.metadataManifest = manifest;
|
|
1051
|
+
}
|
|
533
1052
|
// Issue #666: extract the `[models.*]` subtree (if present) and re-serialize
|
|
534
1053
|
// it to a TOML string for the server's `schema` field.
|
|
535
1054
|
//
|
|
@@ -542,7 +1061,7 @@ export function parseDatabaseTypeToml(tomlData) {
|
|
|
542
1061
|
let schemaSerialized = null;
|
|
543
1062
|
if (tomlData.models && typeof tomlData.models === "object") {
|
|
544
1063
|
try {
|
|
545
|
-
schemaSerialized =
|
|
1064
|
+
schemaSerialized = stringifyConfigToml({ models: tomlData.models });
|
|
546
1065
|
}
|
|
547
1066
|
catch {
|
|
548
1067
|
// Fall through — server-side parser will fail with a clear
|
|
@@ -584,6 +1103,17 @@ export function parseGroupTypeConfigToml(tomlData) {
|
|
|
584
1103
|
else if (section.ruleSetId && section.ruleSetId !== "") {
|
|
585
1104
|
result.ruleSetId = section.ruleSetId;
|
|
586
1105
|
}
|
|
1106
|
+
// Declared-access manifest (issue #1350, extending #1304 P-C): the top-level
|
|
1107
|
+
// `[metadata]` + `secrets` blocks. Validated locally before push; the server
|
|
1108
|
+
// re-validates (graph/schema) at persist time.
|
|
1109
|
+
const manifest = parseDeclaredAccessManifestToml(tomlData);
|
|
1110
|
+
if (manifest) {
|
|
1111
|
+
const manifestError = validateDeclaredAccessManifest(manifest);
|
|
1112
|
+
if (manifestError) {
|
|
1113
|
+
throw new Error(`Invalid metadata manifest in group type config "${section.groupType || ""}": ${manifestError}`);
|
|
1114
|
+
}
|
|
1115
|
+
result.metadataManifest = manifest;
|
|
1116
|
+
}
|
|
587
1117
|
return result;
|
|
588
1118
|
}
|
|
589
1119
|
export function parseCollectionTypeConfigToml(tomlData) {
|
|
@@ -598,6 +1128,17 @@ export function parseCollectionTypeConfigToml(tomlData) {
|
|
|
598
1128
|
else if (section.ruleSetId && section.ruleSetId !== "") {
|
|
599
1129
|
result.ruleSetId = section.ruleSetId;
|
|
600
1130
|
}
|
|
1131
|
+
// Declared-access manifest (issue #1304, P-C): the top-level `[metadata]` +
|
|
1132
|
+
// `secrets` blocks. Validated locally before push; the server re-validates
|
|
1133
|
+
// (graph/schema) at persist time.
|
|
1134
|
+
const manifest = parseDeclaredAccessManifestToml(tomlData);
|
|
1135
|
+
if (manifest) {
|
|
1136
|
+
const manifestError = validateDeclaredAccessManifest(manifest);
|
|
1137
|
+
if (manifestError) {
|
|
1138
|
+
throw new Error(`Invalid metadata manifest in collection type config "${section.collectionType || ""}": ${manifestError}`);
|
|
1139
|
+
}
|
|
1140
|
+
result.metadataManifest = manifest;
|
|
1141
|
+
}
|
|
601
1142
|
return result;
|
|
602
1143
|
}
|
|
603
1144
|
// Parsing helpers
|
|
@@ -609,7 +1150,7 @@ export function parseCollectionTypeConfigToml(tomlData) {
|
|
|
609
1150
|
// the original object untouched.
|
|
610
1151
|
function parseTomlFile(filePath) {
|
|
611
1152
|
const content = readFileSync(filePath, "utf-8");
|
|
612
|
-
const parsed =
|
|
1153
|
+
const parsed = parseConfigToml(content);
|
|
613
1154
|
return expandWorkflowTomlData(parsed, filePath);
|
|
614
1155
|
}
|
|
615
1156
|
/**
|
|
@@ -629,17 +1170,6 @@ export async function fetchAll(listFn, pageSize = 100, maxPages = 100) {
|
|
|
629
1170
|
} while (cursor);
|
|
630
1171
|
return all;
|
|
631
1172
|
}
|
|
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
1173
|
/**
|
|
644
1174
|
* Resolve a key-based rule set name reference to an ID.
|
|
645
1175
|
* Throws if the name cannot be resolved and throwOnMissing is true.
|
|
@@ -681,7 +1211,14 @@ export function resolveSlugCollisions(slug, usedSlugs) {
|
|
|
681
1211
|
return resolved;
|
|
682
1212
|
}
|
|
683
1213
|
function getTestsDir(configDir, blockType, blockKey) {
|
|
684
|
-
|
|
1214
|
+
// Scripts (blockType "script") live under `transforms/` on disk (their
|
|
1215
|
+
// bodies are `transforms/<name>.rhai`), so their test cases sit alongside at
|
|
1216
|
+
// `transforms/<name>.tests/`. Prompts and workflows keep their own dirs.
|
|
1217
|
+
const typeDir = blockType === "prompt"
|
|
1218
|
+
? "prompts"
|
|
1219
|
+
: blockType === "script"
|
|
1220
|
+
? "transforms"
|
|
1221
|
+
: "workflows";
|
|
685
1222
|
return join(configDir, typeDir, `${blockKey}.tests`);
|
|
686
1223
|
}
|
|
687
1224
|
export function serializeTestCase(testCase, lookupMaps) {
|
|
@@ -705,7 +1242,7 @@ export function serializeTestCase(testCase, lookupMaps) {
|
|
|
705
1242
|
expectedJsonSubset: testCase.expectedJsonSubset || "{}",
|
|
706
1243
|
},
|
|
707
1244
|
};
|
|
708
|
-
return
|
|
1245
|
+
return stringifyConfigToml(data);
|
|
709
1246
|
}
|
|
710
1247
|
export function parseTestCaseToml(tomlData) {
|
|
711
1248
|
const test = tomlData.test || {};
|
|
@@ -809,8 +1346,15 @@ export async function pullScripts(client, appId, configDir, logger = () => { })
|
|
|
809
1346
|
items = result.items || [];
|
|
810
1347
|
}
|
|
811
1348
|
catch {
|
|
812
|
-
return {
|
|
1349
|
+
return {
|
|
1350
|
+
scriptEntities,
|
|
1351
|
+
count: 0,
|
|
1352
|
+
presence: { ok: false },
|
|
1353
|
+
listOk: false,
|
|
1354
|
+
serverKeys: [],
|
|
1355
|
+
};
|
|
813
1356
|
}
|
|
1357
|
+
let detailsOk = true;
|
|
814
1358
|
let written = 0;
|
|
815
1359
|
for (const script of items) {
|
|
816
1360
|
const name = script?.name;
|
|
@@ -829,6 +1373,7 @@ export async function pullScripts(client, appId, configDir, logger = () => { })
|
|
|
829
1373
|
full = await client.getScript(appId, script.scriptId);
|
|
830
1374
|
}
|
|
831
1375
|
catch {
|
|
1376
|
+
detailsOk = false;
|
|
832
1377
|
logger(` Skipped transforms/${name}.rhai (could not fetch config)`);
|
|
833
1378
|
continue;
|
|
834
1379
|
}
|
|
@@ -853,7 +1398,11 @@ export async function pullScripts(client, appId, configDir, logger = () => { })
|
|
|
853
1398
|
written += 1;
|
|
854
1399
|
logger(` Wrote transforms/${filename}`);
|
|
855
1400
|
}
|
|
856
|
-
|
|
1401
|
+
const serverKeys = items.map((s) => s?.name).filter(Boolean);
|
|
1402
|
+
const presence = detailsOk
|
|
1403
|
+
? { ok: true, serverKeys }
|
|
1404
|
+
: { ok: false };
|
|
1405
|
+
return { scriptEntities, count: written, presence, listOk: true, serverKeys };
|
|
857
1406
|
}
|
|
858
1407
|
async function pullTestCasesForBlock(client, appId, blockType, blockId, blockKey, configDir, testCaseEntities, lookupMaps) {
|
|
859
1408
|
let testCases;
|
|
@@ -963,8 +1512,10 @@ async function pushTestCasesForBlock(client, appId, blockType, blockId, blockKey
|
|
|
963
1512
|
const configMapKey = `${blockKey}#${testPayload._configName}`;
|
|
964
1513
|
const configMap = blockType === "prompt"
|
|
965
1514
|
? resolutionMaps.promptConfigNameToId
|
|
966
|
-
:
|
|
967
|
-
|
|
1515
|
+
: blockType === "script"
|
|
1516
|
+
? resolutionMaps.scriptConfigNameToId
|
|
1517
|
+
: resolutionMaps.workflowConfigNameToId;
|
|
1518
|
+
const resolvedId = configMap?.get(configMapKey);
|
|
968
1519
|
if (resolvedId) {
|
|
969
1520
|
testPayload.configId = resolvedId;
|
|
970
1521
|
}
|
|
@@ -1166,6 +1717,7 @@ Directory Structure:
|
|
|
1166
1717
|
rule-sets/*.toml # Access rule sets
|
|
1167
1718
|
group-type-configs/*.toml # Group type configs
|
|
1168
1719
|
collection-type-configs/*.toml # Collection type configs
|
|
1720
|
+
metadata-category-configs/*.toml # Resource-metadata category configs
|
|
1169
1721
|
email-templates/*.toml # Email template overrides
|
|
1170
1722
|
`);
|
|
1171
1723
|
// Init
|
|
@@ -1196,6 +1748,7 @@ Directory Structure:
|
|
|
1196
1748
|
ensureDir(join(configDir, "rule-sets"));
|
|
1197
1749
|
ensureDir(join(configDir, "group-type-configs"));
|
|
1198
1750
|
ensureDir(join(configDir, "collection-type-configs"));
|
|
1751
|
+
ensureDir(join(configDir, "metadata-category-configs"));
|
|
1199
1752
|
ensureDir(join(configDir, "email-templates"));
|
|
1200
1753
|
const state = {
|
|
1201
1754
|
appId: resolvedAppId,
|
|
@@ -1215,6 +1768,7 @@ Directory Structure:
|
|
|
1215
1768
|
.option("--app <app-id>", "App ID")
|
|
1216
1769
|
.option("--dir <path>", "Config directory (overrides the auto-resolved per-env path)")
|
|
1217
1770
|
.option("--json", "Output as JSON instead of writing files")
|
|
1771
|
+
.option("--no-prune", "Keep local files for entities that are absent from the sync export (the next push will re-create them)")
|
|
1218
1772
|
.action(async (appId, options) => {
|
|
1219
1773
|
const resolvedAppId = resolveAppId(appId, options);
|
|
1220
1774
|
const configDir = resolveSyncDir({ appId: resolvedAppId, userDir: options.dir });
|
|
@@ -1227,14 +1781,24 @@ Directory Structure:
|
|
|
1227
1781
|
const client = new ApiClient();
|
|
1228
1782
|
info(`Pulling configuration for app ${resolvedAppId}...`);
|
|
1229
1783
|
try {
|
|
1230
|
-
// Fetch all data
|
|
1231
|
-
|
|
1784
|
+
// Fetch all data. Issue #1659: prune decides whether a local file's
|
|
1785
|
+
// entity still exists server-side, so every fetch that establishes
|
|
1786
|
+
// presence must report success or failure explicitly — an error that
|
|
1787
|
+
// reads as an empty list would prune every local file of that type.
|
|
1788
|
+
// `integrations`/`prompts`/`workflows` throw straight out of the pull
|
|
1789
|
+
// on failure (no `.catch`), so reaching the prune pass at all proves
|
|
1790
|
+
// their listings succeeded. The types that swallow errors below carry
|
|
1791
|
+
// an explicit outcome instead.
|
|
1792
|
+
const [settings, integrationItems, promptItems, workflowItems, emailTemplatesOutcome] = await Promise.all([
|
|
1232
1793
|
client.getAppSettings(resolvedAppId).catch(() => null),
|
|
1233
1794
|
fetchAll((p) => client.listIntegrations(resolvedAppId, p)),
|
|
1234
1795
|
fetchAll((p) => client.listPrompts(resolvedAppId, p)),
|
|
1235
1796
|
fetchAll((p) => client.listWorkflows(resolvedAppId, p)),
|
|
1236
|
-
|
|
1797
|
+
attemptFetch(() => client.listEmailTemplates(resolvedAppId)),
|
|
1237
1798
|
]);
|
|
1799
|
+
const emailTemplatesResult = emailTemplatesOutcome.ok
|
|
1800
|
+
? emailTemplatesOutcome.value
|
|
1801
|
+
: { templates: [] };
|
|
1238
1802
|
// Fetch details for each entity
|
|
1239
1803
|
const integrations = await Promise.all(integrationItems.map((i) => client.getIntegration(resolvedAppId, i.integrationId)));
|
|
1240
1804
|
const prompts = await Promise.all(promptItems.map((p) => client.getPrompt(resolvedAppId, p.promptId)));
|
|
@@ -1259,29 +1823,71 @@ Directory Structure:
|
|
|
1259
1823
|
}
|
|
1260
1824
|
return workflowData;
|
|
1261
1825
|
}));
|
|
1262
|
-
// Fetch full details for email template overrides
|
|
1826
|
+
// Fetch full details for email template overrides. Only overrides get a
|
|
1827
|
+
// local file, so they are the type's server key set. A detail fetch that
|
|
1828
|
+
// fails leaves the override unwritten — presence for that key is no
|
|
1829
|
+
// longer established, so the type is skipped for pruning (#1659).
|
|
1263
1830
|
const emailTemplateOverrides = emailTemplatesResult.templates.filter((t) => t.hasOverride);
|
|
1264
|
-
const
|
|
1831
|
+
const emailTemplateOutcomes = await Promise.all(emailTemplateOverrides.map((t) => attemptFetch(() => client.getEmailTemplate(resolvedAppId, t.emailType))));
|
|
1832
|
+
const emailTemplates = emailTemplateOutcomes
|
|
1833
|
+
.map((o) => (o.ok ? o.value : null))
|
|
1834
|
+
.filter(Boolean);
|
|
1835
|
+
const emailTemplateDetailsOk = emailTemplateOutcomes.every((o) => o.ok && o.value);
|
|
1836
|
+
const emailTemplatePresence = emailTemplatesOutcome.ok && emailTemplateDetailsOk
|
|
1837
|
+
? {
|
|
1838
|
+
ok: true,
|
|
1839
|
+
serverKeys: emailTemplateOverrides.map((t) => t.emailType),
|
|
1840
|
+
}
|
|
1841
|
+
: { ok: false };
|
|
1265
1842
|
if (options.json) {
|
|
1266
1843
|
json({ settings, integrations, prompts, workflows, emailTemplates });
|
|
1267
1844
|
return;
|
|
1268
1845
|
}
|
|
1269
1846
|
// Fetch database config resources
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1847
|
+
// These five listings previously used `.catch(() => [])`, which made a
|
|
1848
|
+
// transient 401/500/network error indistinguishable from "the app has
|
|
1849
|
+
// none of these". Pull still tolerates the failure the same way (it
|
|
1850
|
+
// writes nothing for that type and carries on), but it now records the
|
|
1851
|
+
// outcome so the prune pass can skip a type whose presence is unknown
|
|
1852
|
+
// rather than delete every local file of it (#1659).
|
|
1853
|
+
const [databaseTypeConfigsOutcome, ruleSetsOutcome, groupTypeConfigsOutcome, collectionTypeConfigsOutcome, metadataCategoryConfigsOutcome,] = await Promise.all([
|
|
1854
|
+
attemptFetch(() => client.listDatabaseTypeConfigs(resolvedAppId)),
|
|
1855
|
+
attemptFetch(() => client.listRuleSets(resolvedAppId)),
|
|
1856
|
+
attemptFetch(() => client.listGroupTypeConfigs(resolvedAppId)),
|
|
1857
|
+
attemptFetch(() => client.listCollectionTypeConfigs(resolvedAppId)),
|
|
1858
|
+
attemptFetch(() => client.listMetadataCategoryConfigs(resolvedAppId)),
|
|
1275
1859
|
]);
|
|
1860
|
+
const databaseTypeConfigsResult = databaseTypeConfigsOutcome.ok
|
|
1861
|
+
? databaseTypeConfigsOutcome.value
|
|
1862
|
+
: [];
|
|
1863
|
+
const ruleSetsResult = ruleSetsOutcome.ok ? ruleSetsOutcome.value : [];
|
|
1864
|
+
const groupTypeConfigsResult = groupTypeConfigsOutcome.ok
|
|
1865
|
+
? groupTypeConfigsOutcome.value
|
|
1866
|
+
: [];
|
|
1867
|
+
const collectionTypeConfigsResult = collectionTypeConfigsOutcome.ok
|
|
1868
|
+
? collectionTypeConfigsOutcome.value
|
|
1869
|
+
: [];
|
|
1870
|
+
const metadataCategoryConfigsResult = metadataCategoryConfigsOutcome.ok
|
|
1871
|
+
? metadataCategoryConfigsOutcome.value
|
|
1872
|
+
: [];
|
|
1276
1873
|
// Fetch operations + subscriptions for each database type. Issue #803:
|
|
1277
1874
|
// subscriptions are pulled symmetrically with operations so a
|
|
1278
1875
|
// pull → push cycle round-trips `[[subscriptions]]` blocks without a
|
|
1279
1876
|
// spurious diff. The list endpoint already excludes archived rows.
|
|
1877
|
+
//
|
|
1878
|
+
// A failed operation/subscription fetch also disqualifies the type from
|
|
1879
|
+
// pruning (#1659): the file we write for it is then incomplete, so the
|
|
1880
|
+
// pull's picture of that type is not one to reconcile against.
|
|
1881
|
+
let databaseTypeDetailsOk = true;
|
|
1280
1882
|
const databaseTypesWithOps = await Promise.all((Array.isArray(databaseTypeConfigsResult) ? databaseTypeConfigsResult : []).map(async (typeConfig) => {
|
|
1281
|
-
const [
|
|
1282
|
-
client.listDatabaseTypeOperations(resolvedAppId, typeConfig.databaseType)
|
|
1283
|
-
client.listDatabaseTypeSubscriptions(resolvedAppId, typeConfig.databaseType)
|
|
1883
|
+
const [opsOutcome, subsOutcome] = await Promise.all([
|
|
1884
|
+
attemptFetch(() => client.listDatabaseTypeOperations(resolvedAppId, typeConfig.databaseType)),
|
|
1885
|
+
attemptFetch(() => client.listDatabaseTypeSubscriptions(resolvedAppId, typeConfig.databaseType)),
|
|
1284
1886
|
]);
|
|
1887
|
+
if (!opsOutcome.ok || !subsOutcome.ok)
|
|
1888
|
+
databaseTypeDetailsOk = false;
|
|
1889
|
+
const ops = opsOutcome.ok ? opsOutcome.value : [];
|
|
1890
|
+
const subs = subsOutcome.ok ? subsOutcome.value : [];
|
|
1285
1891
|
return {
|
|
1286
1892
|
typeConfig,
|
|
1287
1893
|
operations: Array.isArray(ops) ? ops : [],
|
|
@@ -1330,13 +1936,52 @@ Directory Structure:
|
|
|
1330
1936
|
ensureDir(join(configDir, "rule-sets"));
|
|
1331
1937
|
ensureDir(join(configDir, "group-type-configs"));
|
|
1332
1938
|
ensureDir(join(configDir, "collection-type-configs"));
|
|
1939
|
+
ensureDir(join(configDir, "metadata-category-configs"));
|
|
1333
1940
|
ensureDir(join(configDir, "email-templates"));
|
|
1941
|
+
// The prior sync state is the record of which local files a previous
|
|
1942
|
+
// pull wrote — the discriminator the prune pass keys off (#1659). Read
|
|
1943
|
+
// it now: `saveSyncState` at the end of the pull overwrites it, and the
|
|
1944
|
+
// files themselves are about to be rewritten.
|
|
1945
|
+
const priorState = loadSyncState(configDir);
|
|
1946
|
+
const priorEntities = priorState?.entities ?? {};
|
|
1334
1947
|
// Write app settings
|
|
1335
1948
|
if (settings) {
|
|
1336
1949
|
const appTomlPath = join(configDir, "app.toml");
|
|
1337
1950
|
writeFileSync(appTomlPath, serializeAppSettings(settings));
|
|
1338
1951
|
info(" Wrote app.toml");
|
|
1339
1952
|
}
|
|
1953
|
+
// Write config vars (issue #1423). A single flat vars.toml at the
|
|
1954
|
+
// sync-dir root (like app.toml), holding the per-environment non-secret
|
|
1955
|
+
// scalars. Written (comment-only when empty) so the managed file exists
|
|
1956
|
+
// and a later push knows the vars surface is under sync control.
|
|
1957
|
+
//
|
|
1958
|
+
// Distinguish "app genuinely has zero vars" (a successful empty
|
|
1959
|
+
// response — safe to write the comment-only file) from "fetch failed"
|
|
1960
|
+
// (a transient 401/500/network error). Previously a `.catch(() => [])`
|
|
1961
|
+
// conflated the two, so a transient error silently clobbered a good
|
|
1962
|
+
// local vars.toml with a comment-only file while reporting success. On a
|
|
1963
|
+
// fetch error we now leave the existing vars.toml untouched and preserve
|
|
1964
|
+
// the prior manifest entry so state stays consistent with the file.
|
|
1965
|
+
const varsTomlPathPull = join(configDir, "vars.toml");
|
|
1966
|
+
let varsOutcome;
|
|
1967
|
+
try {
|
|
1968
|
+
varsOutcome = { ok: true, vars: await client.listAppConfigVars(resolvedAppId) };
|
|
1969
|
+
}
|
|
1970
|
+
catch (varErr) {
|
|
1971
|
+
varsOutcome = { ok: false, error: varErr };
|
|
1972
|
+
}
|
|
1973
|
+
const varsPlan = planVarsPull(varsOutcome, varsOutcome.ok ? undefined : priorEntities.vars);
|
|
1974
|
+
const varEntities = varsPlan.varEntities;
|
|
1975
|
+
if (varsPlan.write && varsPlan.content !== null) {
|
|
1976
|
+
writeFileSync(varsTomlPathPull, varsPlan.content);
|
|
1977
|
+
const count = varsOutcome.vars.length;
|
|
1978
|
+
info(` Wrote vars.toml (${count} var${count === 1 ? "" : "s"})`);
|
|
1979
|
+
}
|
|
1980
|
+
else {
|
|
1981
|
+
const err = varsOutcome.error;
|
|
1982
|
+
error(` Skipped vars.toml — failed to fetch config vars: ${err?.message ?? err}. ` +
|
|
1983
|
+
"Left the existing vars.toml untouched.");
|
|
1984
|
+
}
|
|
1340
1985
|
// Write integrations
|
|
1341
1986
|
const integrationEntities = {};
|
|
1342
1987
|
for (const integration of integrations) {
|
|
@@ -1367,15 +2012,13 @@ Directory Structure:
|
|
|
1367
2012
|
};
|
|
1368
2013
|
}
|
|
1369
2014
|
info(` Pulled ${webhooks.length} webhook(s)`);
|
|
1370
|
-
// Pull cron triggers
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
// Cron triggers may not be available on older servers
|
|
1378
|
-
}
|
|
2015
|
+
// Pull cron triggers. An older server without the route (or any other
|
|
2016
|
+
// failure) leaves the type unlisted — tolerated as before, but recorded
|
|
2017
|
+
// so prune skips it rather than reading it as "no cron triggers".
|
|
2018
|
+
const cronTriggersOutcome = await attemptFetch(() => client.listCronTriggers(resolvedAppId));
|
|
2019
|
+
const cronTriggerItems = cronTriggersOutcome.ok
|
|
2020
|
+
? cronTriggersOutcome.value.items || []
|
|
2021
|
+
: [];
|
|
1379
2022
|
const cronTriggersDir = join(configDir, "cron-triggers");
|
|
1380
2023
|
mkdirSync(cronTriggersDir, { recursive: true });
|
|
1381
2024
|
const cronTriggerEntities = {};
|
|
@@ -1391,8 +2034,10 @@ Directory Structure:
|
|
|
1391
2034
|
}
|
|
1392
2035
|
info(` Pulled ${cronTriggerItems.length} cron trigger(s)`);
|
|
1393
2036
|
// Pull blob buckets
|
|
1394
|
-
const
|
|
1395
|
-
const blobBucketItems =
|
|
2037
|
+
const blobBucketsOutcome = await attemptFetch(() => client.listBlobBuckets(resolvedAppId));
|
|
2038
|
+
const blobBucketItems = blobBucketsOutcome.ok
|
|
2039
|
+
? blobBucketsOutcome.value.items || []
|
|
2040
|
+
: [];
|
|
1396
2041
|
const blobBucketsDir = join(configDir, "blob-buckets");
|
|
1397
2042
|
mkdirSync(blobBucketsDir, { recursive: true });
|
|
1398
2043
|
const blobBucketEntities = {};
|
|
@@ -1425,7 +2070,7 @@ Directory Structure:
|
|
|
1425
2070
|
for (const { workflow, draft, configs } of workflows) {
|
|
1426
2071
|
const filename = `${workflow.workflowKey}.toml`;
|
|
1427
2072
|
const filePath = join(configDir, "workflows", filename);
|
|
1428
|
-
writeFileSync(filePath, serializeWorkflow(workflow, draft, configs || []));
|
|
2073
|
+
writeFileSync(filePath, serializeWorkflow(workflow, draft, configs || [], (msg) => info(` ${msg}`)));
|
|
1429
2074
|
// Hash the expanded (post-fragment-splice) form so subsequent pushes
|
|
1430
2075
|
// can detect fragment-only edits. See `computeExpandedContentHash`.
|
|
1431
2076
|
// Pulled workflows never carry `include`, so the expander is a
|
|
@@ -1445,7 +2090,7 @@ Directory Structure:
|
|
|
1445
2090
|
// `transforms/<name>.rhai`, and record `{id, modifiedAt,
|
|
1446
2091
|
// contentHash}` under `entities.scripts[name]` so a subsequent
|
|
1447
2092
|
// `sync push` round-trips without false diffs.
|
|
1448
|
-
const { scriptEntities, count: pulledScriptsCount } = await pullScripts(client, resolvedAppId, configDir, info);
|
|
2093
|
+
const { scriptEntities, count: pulledScriptsCount, presence: scriptPresence, listOk: scriptListOk, serverKeys: scriptServerKeys, } = await pullScripts(client, resolvedAppId, configDir, info);
|
|
1449
2094
|
if (pulledScriptsCount > 0) {
|
|
1450
2095
|
info(` Pulled ${pulledScriptsCount} transform(s)`);
|
|
1451
2096
|
}
|
|
@@ -1468,6 +2113,23 @@ Directory Structure:
|
|
|
1468
2113
|
}
|
|
1469
2114
|
}
|
|
1470
2115
|
}
|
|
2116
|
+
// Include script config names so a pinned script test case
|
|
2117
|
+
// round-trips its `configName` reference (the same way prompt/workflow
|
|
2118
|
+
// configs above do). One extra `configs` fetch per pulled script.
|
|
2119
|
+
for (const [name, entity] of Object.entries(scriptEntities)) {
|
|
2120
|
+
try {
|
|
2121
|
+
const { items: scriptConfigs } = await client.listScriptConfigs(resolvedAppId, entity.id);
|
|
2122
|
+
for (const config of scriptConfigs) {
|
|
2123
|
+
configIdToName.set(config.configId, config.configName);
|
|
2124
|
+
}
|
|
2125
|
+
}
|
|
2126
|
+
catch {
|
|
2127
|
+
// Non-fatal: a script test case pinned to a config just serializes
|
|
2128
|
+
// without a resolvable name (falls back to active on push). The
|
|
2129
|
+
// header/body still pulled fine — `name` retained for clarity.
|
|
2130
|
+
void name;
|
|
2131
|
+
}
|
|
2132
|
+
}
|
|
1471
2133
|
const testCaseLookupMaps = { configIdToName, promptIdToKey };
|
|
1472
2134
|
// Write email templates (use detailed emailTemplates, not summaries)
|
|
1473
2135
|
const emailTemplateEntities = {};
|
|
@@ -1480,19 +2142,10 @@ Directory Structure:
|
|
|
1480
2142
|
};
|
|
1481
2143
|
info(` Wrote email-templates/${filename}`);
|
|
1482
2144
|
}
|
|
1483
|
-
//
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
const localFiles = readdirSync(emailTemplatesDir).filter((f) => f.endsWith(".toml"));
|
|
1488
|
-
for (const file of localFiles) {
|
|
1489
|
-
const localType = basename(file, ".toml");
|
|
1490
|
-
if (!pulledTypes.has(localType)) {
|
|
1491
|
-
unlinkSync(join(emailTemplatesDir, file));
|
|
1492
|
-
info(` Removed email-templates/${file} (override deleted on server)`);
|
|
1493
|
-
}
|
|
1494
|
-
}
|
|
1495
|
-
}
|
|
2145
|
+
// Email-template files for removed overrides used to be cleaned up here
|
|
2146
|
+
// by a hand-coded loop — the only type that pruned at all (#1659). It is
|
|
2147
|
+
// now folded into the general prune pass below, under the same rules and
|
|
2148
|
+
// the same `--no-prune` flag as every other type.
|
|
1496
2149
|
// Pull test cases for prompts and workflows
|
|
1497
2150
|
const testCaseEntities = {};
|
|
1498
2151
|
let totalTestCases = 0;
|
|
@@ -1510,6 +2163,15 @@ Directory Structure:
|
|
|
1510
2163
|
}
|
|
1511
2164
|
totalTestCases += count;
|
|
1512
2165
|
}
|
|
2166
|
+
// Pull test cases for scripts (transforms). Script blocks use
|
|
2167
|
+
// blockType "script"; their tests land under transforms/<name>.tests/.
|
|
2168
|
+
for (const [name, entity] of Object.entries(scriptEntities)) {
|
|
2169
|
+
const count = await pullTestCasesForBlock(client, resolvedAppId, "script", entity.id, name, configDir, testCaseEntities, testCaseLookupMaps);
|
|
2170
|
+
if (count > 0) {
|
|
2171
|
+
info(` Wrote ${count} test case(s) for script: ${name}`);
|
|
2172
|
+
}
|
|
2173
|
+
totalTestCases += count;
|
|
2174
|
+
}
|
|
1513
2175
|
// Build ruleSetId → name map for database types and group type configs
|
|
1514
2176
|
const ruleSets = Array.isArray(ruleSetsResult) ? ruleSetsResult : [];
|
|
1515
2177
|
const ruleSetIdToName = new Map();
|
|
@@ -1566,7 +2228,7 @@ Directory Structure:
|
|
|
1566
2228
|
// Write rule sets
|
|
1567
2229
|
const ruleSetEntities = {};
|
|
1568
2230
|
for (const ruleSet of ruleSets) {
|
|
1569
|
-
const fileKey = (ruleSet
|
|
2231
|
+
const fileKey = ruleSetFileKey(ruleSet);
|
|
1570
2232
|
const filename = `${fileKey}.toml`;
|
|
1571
2233
|
const filePath = join(configDir, "rule-sets", filename);
|
|
1572
2234
|
writeFileSync(filePath, serializeRuleSet(ruleSet));
|
|
@@ -1605,6 +2267,199 @@ Directory Structure:
|
|
|
1605
2267
|
};
|
|
1606
2268
|
info(` Wrote collection-type-configs/${filename}`);
|
|
1607
2269
|
}
|
|
2270
|
+
// Write metadata category configs (issue #1304, P-B). One file per
|
|
2271
|
+
// `{resourceType, category}`; the entity key is the `resourceType#category`
|
|
2272
|
+
// pair so push can match it back.
|
|
2273
|
+
const metadataCategoryConfigEntities = {};
|
|
2274
|
+
const metadataCategoryConfigs = Array.isArray(metadataCategoryConfigsResult)
|
|
2275
|
+
? metadataCategoryConfigsResult
|
|
2276
|
+
: [];
|
|
2277
|
+
for (const config of metadataCategoryConfigs) {
|
|
2278
|
+
const filename = `${config.resourceType}.${config.category}.toml`;
|
|
2279
|
+
const filePath = join(configDir, "metadata-category-configs", filename);
|
|
2280
|
+
writeFileSync(filePath, serializeMetadataCategoryConfig(config));
|
|
2281
|
+
metadataCategoryConfigEntities[`${config.resourceType}#${config.category}`] = {
|
|
2282
|
+
modifiedAt: config.modifiedAt || new Date().toISOString(),
|
|
2283
|
+
contentHash: computeFileHash(filePath),
|
|
2284
|
+
};
|
|
2285
|
+
info(` Wrote metadata-category-configs/${filename}`);
|
|
2286
|
+
}
|
|
2287
|
+
// ── Prune reconciliation (issue #1659) ───────────────────────────────
|
|
2288
|
+
//
|
|
2289
|
+
// Every write above is done, so we now know which entities the server
|
|
2290
|
+
// still exports. A local file is removed iff its key was MANAGED (a
|
|
2291
|
+
// prior pull wrote it, so it is in `priorEntities[<slot>]`) and is
|
|
2292
|
+
// ABSENT from this pull's server keys. A file that was never in prior
|
|
2293
|
+
// state was hand-authored — it stays, and `sync diff` reports it as new.
|
|
2294
|
+
//
|
|
2295
|
+
// Presence comes from the server LISTING, never from "what we just
|
|
2296
|
+
// wrote": several types legitimately skip writing a present entity (a
|
|
2297
|
+
// script with no active config), and pruning those would delete a live
|
|
2298
|
+
// entity's file. A type whose presence could not be established is
|
|
2299
|
+
// skipped whole, and its prior state slot is preserved so the managed
|
|
2300
|
+
// discriminator survives to the next pull.
|
|
2301
|
+
//
|
|
2302
|
+
// This runs after the pre-pull snapshot, so `sync revert` restores
|
|
2303
|
+
// anything pruned in error.
|
|
2304
|
+
const presenceBySlot = {
|
|
2305
|
+
integrations: { ok: true, serverKeys: integrationItems.map((i) => i.integrationKey) },
|
|
2306
|
+
webhooks: { ok: true, serverKeys: webhookItems.map((w) => w.webhookKey) },
|
|
2307
|
+
prompts: { ok: true, serverKeys: promptItems.map((p) => p.promptKey) },
|
|
2308
|
+
workflows: { ok: true, serverKeys: workflowItems.map((w) => w.workflowKey) },
|
|
2309
|
+
cronTriggers: cronTriggersOutcome.ok
|
|
2310
|
+
? { ok: true, serverKeys: cronTriggerItems.map((t) => t.triggerKey) }
|
|
2311
|
+
: { ok: false },
|
|
2312
|
+
blobBuckets: blobBucketsOutcome.ok
|
|
2313
|
+
? { ok: true, serverKeys: blobBucketItems.map((b) => b.bucketKey) }
|
|
2314
|
+
: { ok: false },
|
|
2315
|
+
scripts: scriptPresence,
|
|
2316
|
+
emailTemplates: emailTemplatePresence,
|
|
2317
|
+
databaseTypes: databaseTypeConfigsOutcome.ok && databaseTypeDetailsOk
|
|
2318
|
+
? { ok: true, serverKeys: databaseTypesWithOps.map((d) => d.typeConfig.databaseType) }
|
|
2319
|
+
: { ok: false },
|
|
2320
|
+
ruleSets: ruleSetsOutcome.ok
|
|
2321
|
+
? { ok: true, serverKeys: ruleSets.map((rs) => ruleSetFileKey(rs)) }
|
|
2322
|
+
: { ok: false },
|
|
2323
|
+
groupTypeConfigs: groupTypeConfigsOutcome.ok
|
|
2324
|
+
? { ok: true, serverKeys: groupTypeConfigs.map((c) => c.groupType) }
|
|
2325
|
+
: { ok: false },
|
|
2326
|
+
collectionTypeConfigs: collectionTypeConfigsOutcome.ok
|
|
2327
|
+
? { ok: true, serverKeys: collectionTypeConfigs.map((c) => c.collectionType) }
|
|
2328
|
+
: { ok: false },
|
|
2329
|
+
metadataCategoryConfigs: metadataCategoryConfigsOutcome.ok
|
|
2330
|
+
? {
|
|
2331
|
+
ok: true,
|
|
2332
|
+
serverKeys: metadataCategoryConfigs.map((c) => `${c.resourceType}#${c.category}`),
|
|
2333
|
+
}
|
|
2334
|
+
: { ok: false },
|
|
2335
|
+
};
|
|
2336
|
+
let prunedCount = 0;
|
|
2337
|
+
// Keys whose file this prune deliberately left in place. They are absent
|
|
2338
|
+
// server-side, so nothing wrote them into this pull's entity map — see
|
|
2339
|
+
// the restore pass below `keepPrior`.
|
|
2340
|
+
const keptKeysBySlot = {};
|
|
2341
|
+
for (const resourceType of SYNC_RESOURCE_TYPES) {
|
|
2342
|
+
const presence = presenceBySlot[resourceType.stateSlot] ?? { ok: false };
|
|
2343
|
+
const plan = planPrune({
|
|
2344
|
+
resourceType,
|
|
2345
|
+
priorKeys: Object.keys(priorEntities[resourceType.stateSlot] ?? {}),
|
|
2346
|
+
presence,
|
|
2347
|
+
enabled: options.prune !== false,
|
|
2348
|
+
});
|
|
2349
|
+
if (plan.skipped) {
|
|
2350
|
+
// `--no-prune` is the operator's explicit choice — no warning for it.
|
|
2351
|
+
// A failed fetch is worth saying out loud: stale files survive this
|
|
2352
|
+
// pull, and the next push would re-create their entities.
|
|
2353
|
+
if (plan.skipReason === "fetch-failed" && Object.keys(priorEntities[resourceType.stateSlot] ?? {}).length > 0) {
|
|
2354
|
+
warn(` Skipped pruning ${resourceType.dir} — could not establish which entities the server has; ` +
|
|
2355
|
+
"local files left untouched");
|
|
2356
|
+
}
|
|
2357
|
+
// Only worth saying when a file is actually being left behind:
|
|
2358
|
+
// this listing is incomplete on every pull, so warning
|
|
2359
|
+
// unconditionally would just train operators to ignore it.
|
|
2360
|
+
if (plan.skipReason === "listing-incomplete" && plan.unprunedAbsentKeys.length > 0) {
|
|
2361
|
+
warn(` Skipped pruning ${plan.unprunedAbsentKeys.length} ${resourceType.dir} file(s) — ` +
|
|
2362
|
+
`the server's ${resourceType.dir} listing is not paginated, so an entity missing from it ` +
|
|
2363
|
+
"may still be live (#1668). Delete the file yourself if the entity is really gone: " +
|
|
2364
|
+
plan.unprunedAbsentKeys.join(", "));
|
|
2365
|
+
}
|
|
2366
|
+
// The files stayed, so their keys stay managed — same rule as the
|
|
2367
|
+
// kept-file restore below.
|
|
2368
|
+
if (plan.unprunedAbsentKeys.length > 0) {
|
|
2369
|
+
keptKeysBySlot[resourceType.stateSlot] = [...plan.unprunedAbsentKeys];
|
|
2370
|
+
}
|
|
2371
|
+
continue;
|
|
2372
|
+
}
|
|
2373
|
+
for (const key of plan.unsafeKeys) {
|
|
2374
|
+
warn(` Skipped pruning ${resourceType.dir} entry "${key}" — its sync-state key does not map to a safe filename`);
|
|
2375
|
+
}
|
|
2376
|
+
const result = await applyPrune({
|
|
2377
|
+
configDir,
|
|
2378
|
+
resourceType,
|
|
2379
|
+
keys: plan.keys,
|
|
2380
|
+
testCaseEntities,
|
|
2381
|
+
logger: info,
|
|
2382
|
+
});
|
|
2383
|
+
prunedCount += result.prunedKeys.length;
|
|
2384
|
+
const kept = [...result.keptWithLocalEdits, ...plan.unsafeKeys];
|
|
2385
|
+
if (kept.length > 0) {
|
|
2386
|
+
keptKeysBySlot[resourceType.stateSlot] = kept;
|
|
2387
|
+
}
|
|
2388
|
+
}
|
|
2389
|
+
// Preserve prior state for types whose listing failed: nothing was
|
|
2390
|
+
// written for them, so the untouched local files must keep their
|
|
2391
|
+
// managed marker (the same rule `planVarsPull` applies to vars.toml).
|
|
2392
|
+
// For a type that listed fine but had a per-entity fetch failure, keep
|
|
2393
|
+
// the prior entry for that one key — see `preserveUnwrittenEntries`.
|
|
2394
|
+
const keepPrior = (ok, built, slot) => ok ? built : (priorEntities[slot] ?? built);
|
|
2395
|
+
preserveUnwrittenEntries(scriptEntities, priorEntities.scripts, scriptServerKeys);
|
|
2396
|
+
preserveUnwrittenEntries(emailTemplateEntities, priorEntities.emailTemplates, emailTemplatesOutcome.ok
|
|
2397
|
+
? emailTemplateOverrides.map((t) => t.emailType)
|
|
2398
|
+
: []);
|
|
2399
|
+
const finalCronTriggerEntities = keepPrior(cronTriggersOutcome.ok, cronTriggerEntities, "cronTriggers");
|
|
2400
|
+
const finalBlobBucketEntities = keepPrior(blobBucketsOutcome.ok, blobBucketEntities, "blobBuckets");
|
|
2401
|
+
const finalScriptEntities = keepPrior(scriptListOk, scriptEntities, "scripts");
|
|
2402
|
+
const finalEmailTemplateEntities = keepPrior(emailTemplatesOutcome.ok, emailTemplateEntities, "emailTemplates");
|
|
2403
|
+
const finalDatabaseTypeEntities = keepPrior(databaseTypeConfigsOutcome.ok, databaseTypeEntities, "databaseTypes");
|
|
2404
|
+
const finalRuleSetEntities = keepPrior(ruleSetsOutcome.ok, ruleSetEntities, "ruleSets");
|
|
2405
|
+
const finalGroupTypeConfigEntities = keepPrior(groupTypeConfigsOutcome.ok, groupTypeConfigEntities, "groupTypeConfigs");
|
|
2406
|
+
const finalCollectionTypeConfigEntities = keepPrior(collectionTypeConfigsOutcome.ok, collectionTypeConfigEntities, "collectionTypeConfigs");
|
|
2407
|
+
const finalMetadataCategoryConfigEntities = keepPrior(metadataCategoryConfigsOutcome.ok, metadataCategoryConfigEntities, "metadataCategoryConfigs");
|
|
2408
|
+
// Keep the managed marker on every file prune deliberately left in
|
|
2409
|
+
// place: uncommitted local edits, an unsafe filename, `--no-prune`, or
|
|
2410
|
+
// a listing too incomplete to trust.
|
|
2411
|
+
//
|
|
2412
|
+
// Those keys are absent from the server, so this pull wrote no entry for
|
|
2413
|
+
// them: `preserveUnwrittenEntries` only rescues keys the server LISTED,
|
|
2414
|
+
// and `keepPrior` only fires when a whole listing failed. Without this
|
|
2415
|
+
// pass the key drops out of `entities[<slot>]` here, and on the next pull
|
|
2416
|
+
// the file is indistinguishable from a hand-authored one — never pruned,
|
|
2417
|
+
// never warned about, and reported by `sync diff` as "local only" rather
|
|
2418
|
+
// than absent-from-export. The skip-with-warning safety hatch would work
|
|
2419
|
+
// once and then quietly stop, re-creating the entity on the next push.
|
|
2420
|
+
// Carrying the prior entry forward keeps the file managed until it is
|
|
2421
|
+
// actually gone.
|
|
2422
|
+
const entitiesBySlot = {
|
|
2423
|
+
integrations: integrationEntities,
|
|
2424
|
+
webhooks: webhookEntities,
|
|
2425
|
+
cronTriggers: finalCronTriggerEntities,
|
|
2426
|
+
blobBuckets: finalBlobBucketEntities,
|
|
2427
|
+
prompts: promptEntities,
|
|
2428
|
+
workflows: workflowEntities,
|
|
2429
|
+
scripts: finalScriptEntities,
|
|
2430
|
+
emailTemplates: finalEmailTemplateEntities,
|
|
2431
|
+
databaseTypes: finalDatabaseTypeEntities,
|
|
2432
|
+
ruleSets: finalRuleSetEntities,
|
|
2433
|
+
groupTypeConfigs: finalGroupTypeConfigEntities,
|
|
2434
|
+
collectionTypeConfigs: finalCollectionTypeConfigEntities,
|
|
2435
|
+
metadataCategoryConfigs: finalMetadataCategoryConfigEntities,
|
|
2436
|
+
};
|
|
2437
|
+
for (const [slot, keys] of Object.entries(keptKeysBySlot)) {
|
|
2438
|
+
const built = entitiesBySlot[slot];
|
|
2439
|
+
const prior = priorEntities[slot];
|
|
2440
|
+
if (!built || !prior)
|
|
2441
|
+
continue;
|
|
2442
|
+
for (const key of keys) {
|
|
2443
|
+
if (!(key in built) && key in prior) {
|
|
2444
|
+
built[key] = prior[key];
|
|
2445
|
+
}
|
|
2446
|
+
}
|
|
2447
|
+
// The block's sidecar test cases were not written either (the block is
|
|
2448
|
+
// gone from the export), and its `<key>.tests/` files are still on disk
|
|
2449
|
+
// because the block's own file was kept — so their records carry
|
|
2450
|
+
// forward on the same rule.
|
|
2451
|
+
const resourceType = SYNC_RESOURCE_TYPES.find((rt) => rt.stateSlot === slot);
|
|
2452
|
+
const blockType = resourceType?.testBlockType;
|
|
2453
|
+
if (!blockType || !priorEntities.testCases)
|
|
2454
|
+
continue;
|
|
2455
|
+
for (const [tcKey, tcValue] of Object.entries(priorEntities.testCases)) {
|
|
2456
|
+
if (tcValue.blockType === blockType &&
|
|
2457
|
+
keys.includes(tcValue.blockKey) &&
|
|
2458
|
+
!(tcKey in testCaseEntities)) {
|
|
2459
|
+
testCaseEntities[tcKey] = tcValue;
|
|
2460
|
+
}
|
|
2461
|
+
}
|
|
2462
|
+
}
|
|
1608
2463
|
// Save sync state
|
|
1609
2464
|
const state = {
|
|
1610
2465
|
appId: resolvedAppId,
|
|
@@ -1614,24 +2469,29 @@ Directory Structure:
|
|
|
1614
2469
|
app: settings ? { modifiedAt: new Date().toISOString(), contentHash: computeFileHash(join(configDir, "app.toml")) } : undefined,
|
|
1615
2470
|
integrations: integrationEntities,
|
|
1616
2471
|
webhooks: webhookEntities,
|
|
1617
|
-
cronTriggers: Object.keys(
|
|
1618
|
-
blobBuckets: Object.keys(
|
|
2472
|
+
cronTriggers: Object.keys(finalCronTriggerEntities).length > 0 ? finalCronTriggerEntities : undefined,
|
|
2473
|
+
blobBuckets: Object.keys(finalBlobBucketEntities).length > 0 ? finalBlobBucketEntities : undefined,
|
|
1619
2474
|
prompts: promptEntities,
|
|
1620
2475
|
workflows: workflowEntities,
|
|
1621
|
-
scripts: Object.keys(
|
|
1622
|
-
emailTemplates: Object.keys(
|
|
2476
|
+
scripts: Object.keys(finalScriptEntities).length > 0 ? finalScriptEntities : undefined,
|
|
2477
|
+
emailTemplates: Object.keys(finalEmailTemplateEntities).length > 0 ? finalEmailTemplateEntities : undefined,
|
|
1623
2478
|
testCases: Object.keys(testCaseEntities).length > 0 ? testCaseEntities : undefined,
|
|
1624
|
-
databaseTypes: Object.keys(
|
|
1625
|
-
ruleSets: Object.keys(
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
2479
|
+
databaseTypes: Object.keys(finalDatabaseTypeEntities).length > 0 ? finalDatabaseTypeEntities : undefined,
|
|
2480
|
+
ruleSets: Object.keys(finalRuleSetEntities).length > 0 ? finalRuleSetEntities : undefined,
|
|
2481
|
+
vars: Object.keys(varEntities).length > 0 ? varEntities : undefined,
|
|
2482
|
+
groupTypeConfigs: Object.keys(finalGroupTypeConfigEntities).length > 0 ? finalGroupTypeConfigEntities : undefined,
|
|
2483
|
+
collectionTypeConfigs: Object.keys(finalCollectionTypeConfigEntities).length > 0
|
|
2484
|
+
? finalCollectionTypeConfigEntities
|
|
2485
|
+
: undefined,
|
|
2486
|
+
metadataCategoryConfigs: Object.keys(finalMetadataCategoryConfigEntities).length > 0
|
|
2487
|
+
? finalMetadataCategoryConfigEntities
|
|
1629
2488
|
: undefined,
|
|
1630
2489
|
},
|
|
1631
2490
|
};
|
|
1632
2491
|
saveSyncState(configDir, state);
|
|
1633
2492
|
divider();
|
|
1634
2493
|
success(`Pulled configuration to ${configDir}`);
|
|
2494
|
+
keyValue("Config Vars", Object.keys(varEntities).length);
|
|
1635
2495
|
keyValue("Integrations", integrations.length);
|
|
1636
2496
|
keyValue("Webhooks", webhooks.length);
|
|
1637
2497
|
keyValue("Cron Triggers", cronTriggerItems.length);
|
|
@@ -1645,6 +2505,8 @@ Directory Structure:
|
|
|
1645
2505
|
keyValue("Rule Sets", ruleSets.length);
|
|
1646
2506
|
keyValue("Group Type Configs", groupTypeConfigs.length);
|
|
1647
2507
|
keyValue("Collection Type Configs", collectionTypeConfigs.length);
|
|
2508
|
+
keyValue("Metadata Category Configs", metadataCategoryConfigs.length);
|
|
2509
|
+
keyValue("Pruned", prunedCount);
|
|
1648
2510
|
}
|
|
1649
2511
|
catch (err) {
|
|
1650
2512
|
error(err.message);
|
|
@@ -1896,10 +2758,110 @@ Directory Structure:
|
|
|
1896
2758
|
}
|
|
1897
2759
|
}
|
|
1898
2760
|
}
|
|
2761
|
+
// Validate app.toml settings up-front (issue #1033). A hand-added
|
|
2762
|
+
// `googleClientSecret` key or a mistyped value is a hard error that must
|
|
2763
|
+
// abort BEFORE any mutation — same fail-before-mutate contract as the
|
|
2764
|
+
// vars preflight below. The apply block re-parses for the payload.
|
|
2765
|
+
const preflightAppTomlPath = join(configDir, "app.toml");
|
|
2766
|
+
if (existsSync(preflightAppTomlPath)) {
|
|
2767
|
+
try {
|
|
2768
|
+
const parsedApp = parseTomlFile(preflightAppTomlPath);
|
|
2769
|
+
const appErrors = collectAppSettingsPushErrors(parsedApp, {
|
|
2770
|
+
isCrossAppPush,
|
|
2771
|
+
});
|
|
2772
|
+
for (const e of appErrors) {
|
|
2773
|
+
preflightValidationErrors.push(` app.toml: ${e}`);
|
|
2774
|
+
}
|
|
2775
|
+
}
|
|
2776
|
+
catch (err) {
|
|
2777
|
+
preflightValidationErrors.push(` app.toml: ${err?.message || String(err)}`);
|
|
2778
|
+
}
|
|
2779
|
+
}
|
|
2780
|
+
// Validate config vars up-front (issue #1423 review). Parse vars.toml
|
|
2781
|
+
// and check the same key/value constraints the server enforces here, in
|
|
2782
|
+
// the preflight pass, so an invalid entry aborts BEFORE any mutation
|
|
2783
|
+
// (app settings or a var upsert) is applied. Previously validation ran
|
|
2784
|
+
// mid-push, after app settings could already have been updated.
|
|
2785
|
+
const preflightVarsTomlPath = join(configDir, "vars.toml");
|
|
2786
|
+
let preflightParsedVars = null;
|
|
2787
|
+
if (existsSync(preflightVarsTomlPath)) {
|
|
2788
|
+
try {
|
|
2789
|
+
const parsed = parseTomlFile(preflightVarsTomlPath);
|
|
2790
|
+
for (const e of validateVarsFile(parsed)) {
|
|
2791
|
+
preflightValidationErrors.push(` ${e}`);
|
|
2792
|
+
}
|
|
2793
|
+
preflightParsedVars = parsed;
|
|
2794
|
+
}
|
|
2795
|
+
catch (err) {
|
|
2796
|
+
preflightValidationErrors.push(` vars.toml: ${err?.message || String(err)}`);
|
|
2797
|
+
}
|
|
2798
|
+
}
|
|
1899
2799
|
if (preflightValidationErrors.length > 0) {
|
|
1900
2800
|
throw new Error(`Aborting push: ${preflightValidationErrors.length} TOML validation error(s) — no changes were applied.\n` +
|
|
1901
2801
|
preflightValidationErrors.join("\n"));
|
|
1902
2802
|
}
|
|
2803
|
+
// Fetch the current remote config vars up-front — before ANY mutation
|
|
2804
|
+
// (issue #1423 review pass 3). `planVarsPush` needs this snapshot both
|
|
2805
|
+
// to detect concurrent remote edits and to decide which already-absent
|
|
2806
|
+
// deletes to skip. A failed fetch must FAIL CLOSED: proceeding with an
|
|
2807
|
+
// empty snapshot would silently disable the concurrency guard and let a
|
|
2808
|
+
// changed local value clobber a remote edit. Abort unless `--force`.
|
|
2809
|
+
// Fetching here (not mid-push) keeps the abort fail-before-mutate.
|
|
2810
|
+
let remoteVarsSnapshot = [];
|
|
2811
|
+
// Whether the snapshot above is authoritative. A failed fetch under
|
|
2812
|
+
// `--force` leaves it empty but UNKNOWN (not "the server has no vars") —
|
|
2813
|
+
// `planVarsPush` needs this so it doesn't mistake "absent from the empty
|
|
2814
|
+
// snapshot" for "already deleted remotely" and silently drop deletions
|
|
2815
|
+
// the user asked for (issue #1423 review r-2 P2).
|
|
2816
|
+
let varSnapshotUnavailable = false;
|
|
2817
|
+
if (existsSync(preflightVarsTomlPath)) {
|
|
2818
|
+
try {
|
|
2819
|
+
remoteVarsSnapshot = await client.listAppConfigVars(resolvedAppId);
|
|
2820
|
+
}
|
|
2821
|
+
catch (err) {
|
|
2822
|
+
if (!options.force) {
|
|
2823
|
+
throw new Error(`Aborting push: could not fetch remote config vars to check for ` +
|
|
2824
|
+
`concurrent edits (${err?.message || String(err)}) — no changes ` +
|
|
2825
|
+
`were applied. Re-run with --force to push without the ` +
|
|
2826
|
+
`concurrency guard.`);
|
|
2827
|
+
}
|
|
2828
|
+
// --force: proceed without a snapshot; the guard is intentionally
|
|
2829
|
+
// bypassed and every local var is upserted as authoritative. The
|
|
2830
|
+
// snapshot is unknown, so deletions of removed keys are still
|
|
2831
|
+
// enqueued (idempotent 404 handling absorbs already-absent keys).
|
|
2832
|
+
remoteVarsSnapshot = [];
|
|
2833
|
+
varSnapshotUnavailable = true;
|
|
2834
|
+
}
|
|
2835
|
+
}
|
|
2836
|
+
// Plan the var writes up-front and validate the FINAL server var
|
|
2837
|
+
// count against the cap BEFORE any mutation (issue #1423 review pass
|
|
2838
|
+
// 5). `validateVarsFile` above caps the local file's entry count, but
|
|
2839
|
+
// remote-only vars (on the server, not in vars.toml) still count
|
|
2840
|
+
// toward the server's limit: 95 local entries plus 10 remote-only
|
|
2841
|
+
// vars passes the local check, then a later create is rejected at the
|
|
2842
|
+
// cap after earlier writes were already applied. Computed from the
|
|
2843
|
+
// remote snapshot plus planned creates minus planned deletions. The
|
|
2844
|
+
// check applies even with `--force`: the cap is a server hard limit,
|
|
2845
|
+
// not a conflict guard, so a push that would exceed it cannot succeed
|
|
2846
|
+
// and forcing it would only leave a partial push. (Under `--force`
|
|
2847
|
+
// with a failed snapshot fetch the remote count is unknown; the check
|
|
2848
|
+
// then degrades to the local file count, like the concurrency guard.)
|
|
2849
|
+
let varPlan = null;
|
|
2850
|
+
if (preflightParsedVars) {
|
|
2851
|
+
varPlan = planVarsPush(preflightParsedVars, syncState?.entities?.vars, remoteVarsSnapshot, { force: options.force, snapshotUnavailable: varSnapshotUnavailable });
|
|
2852
|
+
const finalVarCount = countVarsAfterPush(remoteVarsSnapshot, varPlan);
|
|
2853
|
+
if (finalVarCount > MAX_VARS_PER_APP) {
|
|
2854
|
+
const localKeys = new Set(Object.keys(preflightParsedVars));
|
|
2855
|
+
const plannedDeletes = new Set(varPlan.deletions);
|
|
2856
|
+
const remoteOnlyCount = remoteVarsSnapshot.filter((r) => !localKeys.has(r.key) && !plannedDeletes.has(r.key)).length;
|
|
2857
|
+
throw new Error(`Aborting push: it would leave ${finalVarCount} config vars on the server, ` +
|
|
2858
|
+
`exceeding the maximum of ${MAX_VARS_PER_APP} vars per app ` +
|
|
2859
|
+
`(server has ${remoteVarsSnapshot.length} vars, ${remoteOnlyCount} of ` +
|
|
2860
|
+
`which are not in vars.toml) — no changes were applied. Run ` +
|
|
2861
|
+
`\`sync pull\` to bring the remote-only vars into vars.toml, or ` +
|
|
2862
|
+
`delete unneeded vars, then push again.`);
|
|
2863
|
+
}
|
|
2864
|
+
}
|
|
1903
2865
|
// Process app settings
|
|
1904
2866
|
const appTomlPath = join(configDir, "app.toml");
|
|
1905
2867
|
if (existsSync(appTomlPath)) {
|
|
@@ -1911,28 +2873,13 @@ Directory Structure:
|
|
|
1911
2873
|
}
|
|
1912
2874
|
else {
|
|
1913
2875
|
const tomlData = parseTomlFile(appTomlPath);
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
}
|
|
1921
|
-
}
|
|
1922
|
-
if (tomlData.auth) {
|
|
1923
|
-
settings.googleOAuthEnabled = tomlData.auth.googleOAuthEnabled;
|
|
1924
|
-
settings.passkeyEnabled = tomlData.auth.passkeyEnabled;
|
|
1925
|
-
settings.magicLinkEnabled = tomlData.auth.magicLinkEnabled;
|
|
1926
|
-
if (tomlData.auth.passkeys) {
|
|
1927
|
-
settings.passkeyRpConfig = tomlData.auth.passkeys;
|
|
1928
|
-
}
|
|
1929
|
-
}
|
|
1930
|
-
if (tomlData.cors) {
|
|
1931
|
-
settings.corsMode = tomlData.cors.mode;
|
|
1932
|
-
settings.corsAllowedOrigins = tomlData.cors.allowedOrigins;
|
|
1933
|
-
settings.corsAllowCredentials = tomlData.cors.allowCredentials;
|
|
1934
|
-
settings.corsAllowedMethods = tomlData.cors.allowedMethods;
|
|
1935
|
-
settings.corsMaxAge = tomlData.cors.maxAge;
|
|
2876
|
+
// Descriptor-driven parse of every section ([app]/[auth]/[cors]/
|
|
2877
|
+
// [invitations]) — issue #1033. `name` is stripped on cross-app
|
|
2878
|
+
// push. Errors (secret key / type mismatch) already aborted the
|
|
2879
|
+
// push in the preflight pass above, so only warnings remain here.
|
|
2880
|
+
const { settings, warnings: appWarnings } = parseTomlToAppSettings(tomlData, { isCrossAppPush });
|
|
2881
|
+
for (const w of appWarnings) {
|
|
2882
|
+
warn(` ${w}`);
|
|
1936
2883
|
}
|
|
1937
2884
|
changes.push({ type: "app", action: "update", key: "settings" });
|
|
1938
2885
|
if (!options.dryRun) {
|
|
@@ -1948,6 +2895,135 @@ Directory Structure:
|
|
|
1948
2895
|
}
|
|
1949
2896
|
}
|
|
1950
2897
|
}
|
|
2898
|
+
// Process config vars (issue #1423). Read the flat vars.toml, upsert
|
|
2899
|
+
// each key (PUT by key is idempotent — one call covers create+update),
|
|
2900
|
+
// and hard-delete keys removed from the file (nested-entity precedent:
|
|
2901
|
+
// database operations/subscriptions). Pruning runs ONLY when the file
|
|
2902
|
+
// is present, so an absent vars.toml never mass-deletes server vars.
|
|
2903
|
+
//
|
|
2904
|
+
// Key/value validation now runs up-front in the preflight pass above,
|
|
2905
|
+
// so by the time we get here the file is known-valid (values are
|
|
2906
|
+
// strings). The remote-vars snapshot was also fetched up-front (fail
|
|
2907
|
+
// closed) so `planVarsPush` can detect a concurrent remote edit and
|
|
2908
|
+
// report it as a conflict instead of silently overwriting it (review
|
|
2909
|
+
// r-2 P1), and the plan itself was computed up-front so the final
|
|
2910
|
+
// server var count could be validated against the cap before any
|
|
2911
|
+
// mutation (review pass 5).
|
|
2912
|
+
if (varPlan) {
|
|
2913
|
+
skippedCount += varPlan.skippedCount;
|
|
2914
|
+
// Feed var conflicts into the shared accumulator — same convention as
|
|
2915
|
+
// every other entity, so the end-of-run report renders them as
|
|
2916
|
+
// `CONFLICT var: KEY` and the push exits non-zero.
|
|
2917
|
+
for (const c of varPlan.conflicts) {
|
|
2918
|
+
conflicts.push({
|
|
2919
|
+
type: "var",
|
|
2920
|
+
key: c.key,
|
|
2921
|
+
serverModifiedAt: c.serverModifiedAt,
|
|
2922
|
+
localModifiedAt: c.localModifiedAt,
|
|
2923
|
+
});
|
|
2924
|
+
}
|
|
2925
|
+
// Delete removed keys BEFORE upserting new ones (issue #1423 review
|
|
2926
|
+
// pass 3). At the server's 100-var cap, a replacement (remove one key,
|
|
2927
|
+
// add another) is a valid final state, but upserting the new key first
|
|
2928
|
+
// would transiently exceed the cap and the create would be rejected.
|
|
2929
|
+
// Freeing the removed slots first lets the replacement synchronize.
|
|
2930
|
+
// Each removal prints an "Unsetting var X" line so the destructive
|
|
2931
|
+
// action is visible.
|
|
2932
|
+
for (const key of varPlan.deletions) {
|
|
2933
|
+
changes.push({ type: "var", action: "delete", key });
|
|
2934
|
+
if (!options.dryRun) {
|
|
2935
|
+
// Server-side concurrency guard (issue #1423 review r-2 P1): send
|
|
2936
|
+
// the `updatedAt` we last synced so the server rejects the delete
|
|
2937
|
+
// (409 CONFLICT) if the var was edited remotely since — closing the
|
|
2938
|
+
// race between the client snapshot and this DELETE that the
|
|
2939
|
+
// client-side `planVarsPush` guard alone can't. `--force` omits it
|
|
2940
|
+
// and deletes unconditionally.
|
|
2941
|
+
const expectedModifiedAt = options.force
|
|
2942
|
+
? undefined
|
|
2943
|
+
: syncState?.entities?.vars?.[key]?.modifiedAt;
|
|
2944
|
+
try {
|
|
2945
|
+
await client.deleteAppConfigVar(resolvedAppId, key, expectedModifiedAt);
|
|
2946
|
+
info(` Unsetting var ${key}`);
|
|
2947
|
+
if (syncState?.entities?.vars)
|
|
2948
|
+
delete syncState.entities.vars[key];
|
|
2949
|
+
}
|
|
2950
|
+
catch (err) {
|
|
2951
|
+
// A remote edit landed since our snapshot — report a conflict
|
|
2952
|
+
// instead of discarding it (feeds the shared accumulator →
|
|
2953
|
+
// `CONFLICT var: KEY`, non-zero exit).
|
|
2954
|
+
if (err instanceof ConflictError) {
|
|
2955
|
+
conflicts.push({
|
|
2956
|
+
type: "var",
|
|
2957
|
+
key,
|
|
2958
|
+
serverModifiedAt: err.serverModifiedAt,
|
|
2959
|
+
localModifiedAt: expectedModifiedAt || "unknown",
|
|
2960
|
+
});
|
|
2961
|
+
}
|
|
2962
|
+
else if (err instanceof ApiError && err.statusCode === 404) {
|
|
2963
|
+
// A 404 means the var is already absent — the desired
|
|
2964
|
+
// (deleted) state is met, so treat it as an idempotent success
|
|
2965
|
+
// rather than failing the whole push (issue #1423 review pass
|
|
2966
|
+
// 3). The plan omits deletes for keys absent from a fetched
|
|
2967
|
+
// snapshot; this also absorbs the forced no-snapshot fallback
|
|
2968
|
+
// (r-2 P2), where removed keys are enqueued unconditionally.
|
|
2969
|
+
info(` Var ${key} already absent (nothing to unset)`);
|
|
2970
|
+
if (syncState?.entities?.vars)
|
|
2971
|
+
delete syncState.entities.vars[key];
|
|
2972
|
+
}
|
|
2973
|
+
else {
|
|
2974
|
+
throw wrapEntityError(err, "delete", "var", key);
|
|
2975
|
+
}
|
|
2976
|
+
}
|
|
2977
|
+
}
|
|
2978
|
+
}
|
|
2979
|
+
for (const item of varPlan.upserts) {
|
|
2980
|
+
changes.push({ type: "var", action: item.action, key: item.key });
|
|
2981
|
+
if (!options.dryRun) {
|
|
2982
|
+
// Server-side concurrency guard (issue #1423 review r-2 P1): for an
|
|
2983
|
+
// update, send the `updatedAt` we last synced so the server rejects
|
|
2984
|
+
// the write (409 CONFLICT) if the var changed remotely since — this
|
|
2985
|
+
// closes the snapshot→PUT race the client-side guard can't. A
|
|
2986
|
+
// create has no baseline, so instead it sends a create-only
|
|
2987
|
+
// precondition (`expectNotExists`, review r-3 P1a): the server 409s
|
|
2988
|
+
// if the key was created remotely since our snapshot rather than
|
|
2989
|
+
// silently overwriting that concurrent create. `--force` overrides
|
|
2990
|
+
// both.
|
|
2991
|
+
const isCreate = item.action === "create";
|
|
2992
|
+
const expectedModifiedAt = options.force || isCreate
|
|
2993
|
+
? undefined
|
|
2994
|
+
: syncState?.entities?.vars?.[item.key]?.modifiedAt;
|
|
2995
|
+
try {
|
|
2996
|
+
const result = await client.upsertAppConfigVar(resolvedAppId, item.key, { value: item.value }, expectedModifiedAt, { expectNotExists: !options.force && isCreate });
|
|
2997
|
+
if (syncState) {
|
|
2998
|
+
if (!syncState.entities.vars)
|
|
2999
|
+
syncState.entities.vars = {};
|
|
3000
|
+
syncState.entities.vars[item.key] = {
|
|
3001
|
+
// Record the server's returned timestamp (updatedAt) so the
|
|
3002
|
+
// next push's concurrent-edit guard has an accurate baseline.
|
|
3003
|
+
modifiedAt: result?.updatedAt || result?.modifiedAt || new Date().toISOString(),
|
|
3004
|
+
contentHash: item.valueHash,
|
|
3005
|
+
};
|
|
3006
|
+
}
|
|
3007
|
+
}
|
|
3008
|
+
catch (err) {
|
|
3009
|
+
// A remote edit landed since our snapshot — report a conflict
|
|
3010
|
+
// instead of clobbering it (feeds the shared accumulator →
|
|
3011
|
+
// `CONFLICT var: KEY`, non-zero exit).
|
|
3012
|
+
if (err instanceof ConflictError) {
|
|
3013
|
+
conflicts.push({
|
|
3014
|
+
type: "var",
|
|
3015
|
+
key: item.key,
|
|
3016
|
+
serverModifiedAt: err.serverModifiedAt,
|
|
3017
|
+
localModifiedAt: expectedModifiedAt || "unknown",
|
|
3018
|
+
});
|
|
3019
|
+
}
|
|
3020
|
+
else {
|
|
3021
|
+
throw wrapEntityError(err, item.action, "var", item.key);
|
|
3022
|
+
}
|
|
3023
|
+
}
|
|
3024
|
+
}
|
|
3025
|
+
}
|
|
3026
|
+
}
|
|
1951
3027
|
// Process rule sets first (other entities may reference them by name)
|
|
1952
3028
|
const ruleSetsDir = join(configDir, "rule-sets");
|
|
1953
3029
|
if (existsSync(ruleSetsDir)) {
|
|
@@ -2142,6 +3218,14 @@ Directory Structure:
|
|
|
2142
3218
|
deduplicationWindowMs: webhook.deduplicationWindowMs,
|
|
2143
3219
|
secretGracePeriodMs: webhook.secretGracePeriodMs,
|
|
2144
3220
|
};
|
|
3221
|
+
// Forward the declared signing secret so a `{{secrets.KEY}}`
|
|
3222
|
+
// reference reaches the server intact (#1235). Without this the
|
|
3223
|
+
// value declared in TOML is silently dropped and the server
|
|
3224
|
+
// rejects a verified webhook with "signingSecret is required".
|
|
3225
|
+
// Mirrors how integrations forward requestConfig with refs intact.
|
|
3226
|
+
if (webhook.signingSecret !== undefined) {
|
|
3227
|
+
payload.signingSecret = webhook.signingSecret;
|
|
3228
|
+
}
|
|
2145
3229
|
// Handle JSON fields
|
|
2146
3230
|
if (tomlData.allowedIps?.cidrs) {
|
|
2147
3231
|
payload.allowedIpCidrs = JSON.stringify(tomlData.allowedIps.cidrs);
|
|
@@ -2346,30 +3430,27 @@ Directory Structure:
|
|
|
2346
3430
|
if (existingId) {
|
|
2347
3431
|
// #1020 (D8): idempotently apply preset/ruleSet/name/description
|
|
2348
3432
|
// 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;
|
|
3433
|
+
const updatePayload = buildBlobBucketUpdatePayload(bucket);
|
|
2360
3434
|
changes.push({ type: "blob-bucket", action: "update", key });
|
|
2361
3435
|
if (!options.dryRun) {
|
|
2362
3436
|
try {
|
|
2363
3437
|
await client.updateBlobBucket(resolvedAppId, existingId, updatePayload);
|
|
2364
3438
|
info(` Updated blob bucket: ${key}`);
|
|
3439
|
+
// #1459: stamp the content hash ONLY after a successful
|
|
3440
|
+
// update, and only when this is not a dry run. A failed
|
|
3441
|
+
// update (or a dry run) must leave the file pending rather
|
|
3442
|
+
// than mark it in-sync and suppress it on the next push.
|
|
3443
|
+
if (syncState?.entities?.blobBuckets?.[key]) {
|
|
3444
|
+
syncState.entities.blobBuckets[key].contentHash =
|
|
3445
|
+
computeFileHash(filePath);
|
|
3446
|
+
}
|
|
2365
3447
|
}
|
|
2366
3448
|
catch (err) {
|
|
3449
|
+
// Keep the warn-and-continue contract (sponsor decision):
|
|
3450
|
+
// do not abort the push, just don't stamp the hash.
|
|
2367
3451
|
warn(` Failed to update blob bucket ${key}: ${String(err?.message || err)}`);
|
|
2368
3452
|
}
|
|
2369
3453
|
}
|
|
2370
|
-
if (syncState?.entities?.blobBuckets?.[key]) {
|
|
2371
|
-
syncState.entities.blobBuckets[key].contentHash = computeFileHash(filePath);
|
|
2372
|
-
}
|
|
2373
3454
|
}
|
|
2374
3455
|
else {
|
|
2375
3456
|
const payload = {
|
|
@@ -2404,23 +3485,86 @@ Directory Structure:
|
|
|
2404
3485
|
catch (err) {
|
|
2405
3486
|
const msg = String(err?.message || err);
|
|
2406
3487
|
if (msg.includes("already exists") || err.statusCode === 409) {
|
|
2407
|
-
|
|
2408
|
-
//
|
|
3488
|
+
// #1459: adopt-by-key on 409. A blob bucket can already
|
|
3489
|
+
// exist on the server (an out-of-band create, or a prior
|
|
3490
|
+
// push that aborted before recording it in sync state). The
|
|
3491
|
+
// old handler stamped the LOCAL file's contentHash WITHOUT
|
|
3492
|
+
// applying the local config, so a divergent bucket was
|
|
3493
|
+
// silently marked in-sync and never reconciled. Mirror the
|
|
3494
|
+
// cron/workflow adopt paths: look up the bucket by key,
|
|
3495
|
+
// re-issue the local payload as an UPDATE, and stamp the
|
|
3496
|
+
// hash ONLY after the update succeeds.
|
|
3497
|
+
info(` Blob bucket already exists on server, adopting by key: ${key}`);
|
|
3498
|
+
let existing;
|
|
2409
3499
|
try {
|
|
2410
|
-
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
|
|
2419
|
-
|
|
3500
|
+
existing = await client.getBlobBucket(resolvedAppId, key);
|
|
3501
|
+
}
|
|
3502
|
+
catch (lookupErr) {
|
|
3503
|
+
// A lookup failure after a 409 means we cannot adopt —
|
|
3504
|
+
// surface it instead of swallowing it (the old silent
|
|
3505
|
+
// `catch {}` left no sync-state entry, looping
|
|
3506
|
+
// create→409 on every push).
|
|
3507
|
+
throw wrapEntityError(new Error(`blob bucket "${key}" already exists but could not be adopted (lookup failed: ${String(lookupErr?.message || lookupErr)})`), "create", "blob bucket", key);
|
|
3508
|
+
}
|
|
3509
|
+
if (!existing?.bucketId) {
|
|
3510
|
+
// 409 but no matching bucket found — surface the original
|
|
3511
|
+
// create error rather than silently proceeding.
|
|
3512
|
+
throw wrapEntityError(err, "create", "blob bucket", key);
|
|
3513
|
+
}
|
|
3514
|
+
// Verify the resolved bucket is actually the one we asked
|
|
3515
|
+
// for. `getBlobBucket` resolves the identifier by primary id
|
|
3516
|
+
// (appId, bucketId) BEFORE bucketKey, and a bucketKey may be
|
|
3517
|
+
// a ULID-shaped string (the key regex permits it). If the
|
|
3518
|
+
// local key collides with an unrelated bucket's bucketId,
|
|
3519
|
+
// the lookup returns that unrelated bucket — patching and
|
|
3520
|
+
// stamping it would overwrite the wrong resource. Require an
|
|
3521
|
+
// exact key match, matching the cron/workflow adopt paths'
|
|
3522
|
+
// exact-key equality (they list + filter by key for the same
|
|
3523
|
+
// reason). Surface the mismatch via wrapEntityError; do NOT
|
|
3524
|
+
// patch or stamp.
|
|
3525
|
+
if (existing.bucketKey !== key) {
|
|
3526
|
+
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);
|
|
3527
|
+
}
|
|
3528
|
+
// ttlTier is immutable and cannot be reconciled by an
|
|
3529
|
+
// UPDATE. A local TOML whose retention tier diverges from
|
|
3530
|
+
// the server must fail loudly (sponsor decision) rather
|
|
3531
|
+
// than be silently blessed while the cosmetic fields
|
|
3532
|
+
// converge.
|
|
3533
|
+
if (bucket.ttlTier &&
|
|
3534
|
+
existing.ttlTier &&
|
|
3535
|
+
bucket.ttlTier !== existing.ttlTier) {
|
|
3536
|
+
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);
|
|
3537
|
+
}
|
|
3538
|
+
const updatePayload = buildBlobBucketUpdatePayload(bucket);
|
|
3539
|
+
let updated;
|
|
3540
|
+
if (Object.keys(updatePayload).length > 0) {
|
|
3541
|
+
// Surface an update failure via wrapEntityError (no
|
|
3542
|
+
// stamp) — matches the cron adopt path.
|
|
3543
|
+
try {
|
|
3544
|
+
updated = await client.updateBlobBucket(resolvedAppId, existing.bucketId, updatePayload);
|
|
3545
|
+
}
|
|
3546
|
+
catch (updateErr) {
|
|
3547
|
+
throw wrapEntityError(updateErr, "update", "blob bucket", key);
|
|
2420
3548
|
}
|
|
3549
|
+
info(` Adopted + updated blob bucket: ${key}`);
|
|
2421
3550
|
}
|
|
2422
|
-
|
|
2423
|
-
//
|
|
3551
|
+
else {
|
|
3552
|
+
// No mutable fields in the local TOML — the server
|
|
3553
|
+
// rejects an empty update (400). Adopt the existing
|
|
3554
|
+
// bucket without a PATCH.
|
|
3555
|
+
info(` Adopted blob bucket (no updatable fields): ${key}`);
|
|
3556
|
+
}
|
|
3557
|
+
if (syncState) {
|
|
3558
|
+
if (!syncState.entities.blobBuckets) {
|
|
3559
|
+
syncState.entities.blobBuckets = {};
|
|
3560
|
+
}
|
|
3561
|
+
syncState.entities.blobBuckets[key] = {
|
|
3562
|
+
id: existing.bucketId,
|
|
3563
|
+
modifiedAt: updated?.modifiedAt ||
|
|
3564
|
+
existing.modifiedAt ||
|
|
3565
|
+
new Date().toISOString(),
|
|
3566
|
+
contentHash: computeFileHash(filePath),
|
|
3567
|
+
};
|
|
2424
3568
|
}
|
|
2425
3569
|
}
|
|
2426
3570
|
else {
|
|
@@ -2774,6 +3918,19 @@ Directory Structure:
|
|
|
2774
3918
|
const workflow = tomlData.workflow || {};
|
|
2775
3919
|
const key = workflow.key || basename(file, ".toml");
|
|
2776
3920
|
const steps = tomlData.steps || [];
|
|
3921
|
+
// #1304 (P-C): the declared-access manifest lives at top-level
|
|
3922
|
+
// `[metadata]` / `secrets` (same shape the database/collection type
|
|
3923
|
+
// configs use). Parse + surface-validate it, then carry it on the
|
|
3924
|
+
// `workflow` payload object so it flows through both the create call
|
|
3925
|
+
// and the shared `applyWorkflowBody` update. A null manifest (no
|
|
3926
|
+
// `[metadata]`) clears it on the server so pull → push round-trips a
|
|
3927
|
+
// drop; the deep graph check runs server-side at persist.
|
|
3928
|
+
const workflowManifest = parseDeclaredAccessManifestToml(tomlData);
|
|
3929
|
+
const workflowManifestError = validateDeclaredAccessManifest(workflowManifest);
|
|
3930
|
+
if (workflowManifestError) {
|
|
3931
|
+
throw new Error(`${filePath}: ${workflowManifestError}`);
|
|
3932
|
+
}
|
|
3933
|
+
workflow.metadataManifest = workflowManifest;
|
|
2777
3934
|
const existingId = syncState?.entities?.workflows?.[key]?.id;
|
|
2778
3935
|
const existingActiveConfigId = syncState?.entities?.workflows?.[key]?.activeConfigId;
|
|
2779
3936
|
// Skip if file hasn't changed since last sync. Use the expanded
|
|
@@ -2805,80 +3962,17 @@ Directory Structure:
|
|
|
2805
3962
|
// out-of-manifest workflow).
|
|
2806
3963
|
const applyWorkflowUpdate = async (workflowId, opts) => {
|
|
2807
3964
|
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
|
-
}
|
|
3965
|
+
// The ordering-sensitive core (metadata PATCH → config/draft
|
|
3966
|
+
// steps → deferred `syncCallable` PATCH, #807) lives in the
|
|
3967
|
+
// shared `applyWorkflowBody` helper so `workflows update
|
|
3968
|
+
// --from-file` runs it identically (#1249, Fork 4). The
|
|
3969
|
+
// sync-specific bookkeeping (the progress log, sync-state
|
|
3970
|
+
// `modifiedAt`/hash, and config name→ID map) stays here.
|
|
3971
|
+
const { latestModifiedAt, updateSlotLabel, fullWorkflow } = await applyWorkflowBody(client, resolvedAppId, workflowId, workflow, steps, {
|
|
3972
|
+
expectedModifiedAt,
|
|
3973
|
+
activeConfigId,
|
|
3974
|
+
force: options.force,
|
|
3975
|
+
});
|
|
2882
3976
|
info(` Updated workflow: ${key} (${updateSlotLabel})`);
|
|
2883
3977
|
// Update sync state with new modifiedAt. Store the *expanded*
|
|
2884
3978
|
// content hash so future fragment-only edits are detected. The
|
|
@@ -2888,9 +3982,8 @@ Directory Structure:
|
|
|
2888
3982
|
syncState.entities.workflows[key].modifiedAt = latestModifiedAt;
|
|
2889
3983
|
syncState.entities.workflows[key].contentHash = computeExpandedContentHash(tomlData);
|
|
2890
3984
|
}
|
|
2891
|
-
//
|
|
2892
|
-
// (
|
|
2893
|
-
const fullWorkflow = await client.getWorkflow(resolvedAppId, workflowId);
|
|
3985
|
+
// `applyWorkflowBody` already fetched the full workflow; reuse it
|
|
3986
|
+
// for the config name→ID mappings (the PATCH response omits configs).
|
|
2894
3987
|
if (fullWorkflow?.configs) {
|
|
2895
3988
|
for (const config of fullWorkflow.configs) {
|
|
2896
3989
|
workflowConfigNameToId.set(`${key}#${config.configName}`, config.configId);
|
|
@@ -2930,25 +4023,22 @@ Directory Structure:
|
|
|
2930
4023
|
changes.push({ type: "workflow", action: "create", key });
|
|
2931
4024
|
if (!options.dryRun) {
|
|
2932
4025
|
try {
|
|
4026
|
+
// #1177: build the `[workflow]`-derived fields through the
|
|
4027
|
+
// shared builder so `sync push` create can't drift from
|
|
4028
|
+
// `create --from-file` / the update path (this branch used to
|
|
4029
|
+
// drop `status`, `capabilities`, `perAppMax*`, `queueTtlSeconds`).
|
|
4030
|
+
// Structural fields (`workflowKey`, `steps`, `metadataManifest`)
|
|
4031
|
+
// and the manifest-key `name` fallback are attached here; the
|
|
4032
|
+
// `name` override follows the spread so it wins over the
|
|
4033
|
+
// builder's bare `name`.
|
|
2933
4034
|
const created = await client.createWorkflow(resolvedAppId, {
|
|
2934
4035
|
workflowKey: key,
|
|
2935
|
-
name: workflow.name || key,
|
|
2936
|
-
description: workflow.description,
|
|
2937
4036
|
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,
|
|
4037
|
+
...buildWorkflowPayloadFromToml(workflow, { mode: "create" }),
|
|
4038
|
+
name: workflow.name || key,
|
|
4039
|
+
// #1304 (P-C): declared-access manifest (absent → undefined,
|
|
4040
|
+
// server default null).
|
|
4041
|
+
metadataManifest: workflow.metadataManifest ?? undefined,
|
|
2952
4042
|
});
|
|
2953
4043
|
info(` Created workflow: ${key}`);
|
|
2954
4044
|
// Add new entity to sync state (including activeConfigId)
|
|
@@ -3214,6 +4304,14 @@ Directory Structure:
|
|
|
3214
4304
|
if ("timestamps" in typeConfig) {
|
|
3215
4305
|
u.timestamps = typeConfig.timestamps || null;
|
|
3216
4306
|
}
|
|
4307
|
+
// Issue #1304 (P-C): declared-access manifest. `parseDatabaseTypeToml`
|
|
4308
|
+
// only sets `metadataManifest` when the TOML declares a `[metadata]`
|
|
4309
|
+
// / `secrets` block, so this forwards a set/update; clearing a
|
|
4310
|
+
// manifest by removing the block is a follow-on (mirrors the
|
|
4311
|
+
// schema-deletion tracking).
|
|
4312
|
+
if ("metadataManifest" in typeConfig) {
|
|
4313
|
+
u.metadataManifest = typeConfig.metadataManifest || null;
|
|
4314
|
+
}
|
|
3217
4315
|
const localHasSchema = typeof typeConfig.schema === "string" &&
|
|
3218
4316
|
typeConfig.schema.trim().length > 0;
|
|
3219
4317
|
if (localHasSchema) {
|
|
@@ -3583,7 +4681,8 @@ Directory Structure:
|
|
|
3583
4681
|
"defaultAccess" in typeConfig ||
|
|
3584
4682
|
"autoPopulatedFields" in typeConfig ||
|
|
3585
4683
|
"timestamps" in typeConfig ||
|
|
3586
|
-
"schema" in typeConfig
|
|
4684
|
+
"schema" in typeConfig ||
|
|
4685
|
+
"metadataManifest" in typeConfig;
|
|
3587
4686
|
if (wouldUpdate) {
|
|
3588
4687
|
changes.push({ type: "database-type", action: "update", key: dbType });
|
|
3589
4688
|
}
|
|
@@ -3616,6 +4715,9 @@ Directory Structure:
|
|
|
3616
4715
|
// validating against the intended shape.
|
|
3617
4716
|
if (typeConfig.schema)
|
|
3618
4717
|
createData.schema = typeConfig.schema;
|
|
4718
|
+
// Issue #1304 (P-C): declared-access manifest.
|
|
4719
|
+
if (typeConfig.metadataManifest)
|
|
4720
|
+
createData.metadataManifest = typeConfig.metadataManifest;
|
|
3619
4721
|
try {
|
|
3620
4722
|
const created = await client.createDatabaseTypeConfig(resolvedAppId, createData);
|
|
3621
4723
|
info(` Created database type: ${dbType}`);
|
|
@@ -4072,6 +5174,10 @@ Directory Structure:
|
|
|
4072
5174
|
const updated = await client.updateGroupTypeConfig(resolvedAppId, groupType, {
|
|
4073
5175
|
ruleSetId: configData.ruleSetId,
|
|
4074
5176
|
autoAddCreator: configData.autoAddCreator,
|
|
5177
|
+
// Issue #1350: null clears the manifest server-side when
|
|
5178
|
+
// the TOML drops the `[metadata]` block (matches the
|
|
5179
|
+
// collection type config update semantics).
|
|
5180
|
+
metadataManifest: configData.metadataManifest ?? null,
|
|
4075
5181
|
}, expectedModifiedAt);
|
|
4076
5182
|
info(` Updated group type config: ${groupType}`);
|
|
4077
5183
|
if (syncState?.entities?.groupTypeConfigs?.[groupType] && updated?.modifiedAt) {
|
|
@@ -4103,6 +5209,9 @@ Directory Structure:
|
|
|
4103
5209
|
groupType,
|
|
4104
5210
|
ruleSetId: configData.ruleSetId || undefined,
|
|
4105
5211
|
autoAddCreator: configData.autoAddCreator,
|
|
5212
|
+
...(configData.metadataManifest
|
|
5213
|
+
? { metadataManifest: configData.metadataManifest }
|
|
5214
|
+
: {}),
|
|
4106
5215
|
});
|
|
4107
5216
|
info(` Created group type config: ${groupType}`);
|
|
4108
5217
|
if (syncState) {
|
|
@@ -4149,6 +5258,7 @@ Directory Structure:
|
|
|
4149
5258
|
try {
|
|
4150
5259
|
const updated = await client.updateCollectionTypeConfig(resolvedAppId, collectionType, {
|
|
4151
5260
|
ruleSetId: configData.ruleSetId ?? null,
|
|
5261
|
+
metadataManifest: configData.metadataManifest ?? null,
|
|
4152
5262
|
}, expectedModifiedAt);
|
|
4153
5263
|
info(` Updated collection type config: ${collectionType}`);
|
|
4154
5264
|
if (syncState?.entities?.collectionTypeConfigs?.[collectionType] && updated?.modifiedAt) {
|
|
@@ -4179,6 +5289,9 @@ Directory Structure:
|
|
|
4179
5289
|
const created = await client.createCollectionTypeConfig(resolvedAppId, {
|
|
4180
5290
|
collectionType,
|
|
4181
5291
|
ruleSetId: configData.ruleSetId || undefined,
|
|
5292
|
+
...(configData.metadataManifest
|
|
5293
|
+
? { metadataManifest: configData.metadataManifest }
|
|
5294
|
+
: {}),
|
|
4182
5295
|
});
|
|
4183
5296
|
info(` Created collection type config: ${collectionType}`);
|
|
4184
5297
|
if (syncState) {
|
|
@@ -4198,6 +5311,69 @@ Directory Structure:
|
|
|
4198
5311
|
}
|
|
4199
5312
|
}
|
|
4200
5313
|
}
|
|
5314
|
+
// Process metadata category configs (issue #1304, P-B). Idempotent
|
|
5315
|
+
// upsert via the path-addressed PUT route — no create/update split.
|
|
5316
|
+
const metadataCategoryConfigsDir = join(configDir, "metadata-category-configs");
|
|
5317
|
+
if (existsSync(metadataCategoryConfigsDir)) {
|
|
5318
|
+
const files = readdirSync(metadataCategoryConfigsDir).filter((f) => f.endsWith(".toml"));
|
|
5319
|
+
for (const file of files) {
|
|
5320
|
+
const filePath = join(metadataCategoryConfigsDir, file);
|
|
5321
|
+
const tomlData = parseTomlFile(filePath);
|
|
5322
|
+
const configData = parseMetadataCategoryToml(tomlData);
|
|
5323
|
+
// Fall back to the filename (`resourceType.category.toml`) if the
|
|
5324
|
+
// section omits the identity fields.
|
|
5325
|
+
if (!configData.resourceType || !configData.category) {
|
|
5326
|
+
const base = basename(file, ".toml");
|
|
5327
|
+
const dot = base.indexOf(".");
|
|
5328
|
+
if (dot > 0) {
|
|
5329
|
+
configData.resourceType = configData.resourceType || base.slice(0, dot);
|
|
5330
|
+
configData.category = configData.category || base.slice(dot + 1);
|
|
5331
|
+
}
|
|
5332
|
+
}
|
|
5333
|
+
const validationError = validateMetadataCategoryConfig(configData);
|
|
5334
|
+
if (validationError) {
|
|
5335
|
+
throw new Error(`Invalid metadata category config in ${file}: ${validationError}`);
|
|
5336
|
+
}
|
|
5337
|
+
const entityKey = `${configData.resourceType}#${configData.category}`;
|
|
5338
|
+
const existingEntry = syncState?.entities?.metadataCategoryConfigs?.[entityKey];
|
|
5339
|
+
// Skip if the file hasn't changed since last sync.
|
|
5340
|
+
if (!options.force && existingEntry && !shouldPushFile(filePath, existingEntry.contentHash)) {
|
|
5341
|
+
skippedCount++;
|
|
5342
|
+
continue;
|
|
5343
|
+
}
|
|
5344
|
+
changes.push({
|
|
5345
|
+
type: "metadata-category-config",
|
|
5346
|
+
action: existingEntry ? "update" : "create",
|
|
5347
|
+
key: entityKey,
|
|
5348
|
+
});
|
|
5349
|
+
if (!options.dryRun) {
|
|
5350
|
+
try {
|
|
5351
|
+
const saved = await client.upsertMetadataCategoryConfig(resolvedAppId, configData.resourceType, configData.category, {
|
|
5352
|
+
schema: configData.schema,
|
|
5353
|
+
readRule: configData.readRule,
|
|
5354
|
+
writeRule: configData.writeRule,
|
|
5355
|
+
description: configData.description,
|
|
5356
|
+
// #1420 Phase 2: send the declared-access manifest so a
|
|
5357
|
+
// category's md.self/secrets access round-trips through sync.
|
|
5358
|
+
metadataManifest: configData.metadataManifest,
|
|
5359
|
+
});
|
|
5360
|
+
info(` Upserted metadata category config: ${entityKey}`);
|
|
5361
|
+
if (syncState) {
|
|
5362
|
+
if (!syncState.entities.metadataCategoryConfigs) {
|
|
5363
|
+
syncState.entities.metadataCategoryConfigs = {};
|
|
5364
|
+
}
|
|
5365
|
+
syncState.entities.metadataCategoryConfigs[entityKey] = {
|
|
5366
|
+
modifiedAt: saved?.modifiedAt || new Date().toISOString(),
|
|
5367
|
+
contentHash: computeFileHash(filePath),
|
|
5368
|
+
};
|
|
5369
|
+
}
|
|
5370
|
+
}
|
|
5371
|
+
catch (err) {
|
|
5372
|
+
throw wrapEntityError(err, existingEntry ? "update" : "create", "metadata category config", entityKey);
|
|
5373
|
+
}
|
|
5374
|
+
}
|
|
5375
|
+
}
|
|
5376
|
+
}
|
|
4201
5377
|
// Process email templates
|
|
4202
5378
|
const emailTemplatesDir = join(configDir, "email-templates");
|
|
4203
5379
|
if (existsSync(emailTemplatesDir)) {
|
|
@@ -4234,11 +5410,13 @@ Directory Structure:
|
|
|
4234
5410
|
}
|
|
4235
5411
|
}
|
|
4236
5412
|
}
|
|
4237
|
-
// Push test cases for prompts and
|
|
5413
|
+
// Push test cases for prompts, workflows, and scripts
|
|
5414
|
+
const scriptConfigNameToId = new Map(); // "scriptName#configName" → configId
|
|
4238
5415
|
const pushMaps = {
|
|
4239
5416
|
promptKeyToId,
|
|
4240
5417
|
promptConfigNameToId,
|
|
4241
5418
|
workflowConfigNameToId,
|
|
5419
|
+
scriptConfigNameToId,
|
|
4242
5420
|
};
|
|
4243
5421
|
const promptsDir2 = join(configDir, "prompts");
|
|
4244
5422
|
if (existsSync(promptsDir2)) {
|
|
@@ -4262,6 +5440,33 @@ Directory Structure:
|
|
|
4262
5440
|
}
|
|
4263
5441
|
}
|
|
4264
5442
|
}
|
|
5443
|
+
// Push test cases for scripts (transforms). Each script's tests live at
|
|
5444
|
+
// transforms/<name>.tests/; the scriptId was recorded in
|
|
5445
|
+
// entities.scripts[name].id when the script body was pushed above.
|
|
5446
|
+
const transformsDir2 = join(configDir, "transforms");
|
|
5447
|
+
if (existsSync(transformsDir2)) {
|
|
5448
|
+
for (const file of readdirSync(transformsDir2).filter((f) => f.endsWith(".rhai"))) {
|
|
5449
|
+
const scriptName = basename(file, ".rhai");
|
|
5450
|
+
const blockId = syncState?.entities?.scripts?.[scriptName]?.id;
|
|
5451
|
+
if (!blockId)
|
|
5452
|
+
continue;
|
|
5453
|
+
const scriptTestsDir = join(transformsDir2, `${scriptName}.tests`);
|
|
5454
|
+
if (!existsSync(scriptTestsDir))
|
|
5455
|
+
continue;
|
|
5456
|
+
// Populate scriptConfigNameToId for this script so a test case that
|
|
5457
|
+
// pins a config by name resolves to a live configId.
|
|
5458
|
+
try {
|
|
5459
|
+
const { items: scriptConfigs } = await client.listScriptConfigs(resolvedAppId, blockId);
|
|
5460
|
+
for (const config of scriptConfigs) {
|
|
5461
|
+
scriptConfigNameToId.set(`${scriptName}#${config.configName}`, config.configId);
|
|
5462
|
+
}
|
|
5463
|
+
}
|
|
5464
|
+
catch {
|
|
5465
|
+
// Non-fatal — unpinned (active-version) test cases still push.
|
|
5466
|
+
}
|
|
5467
|
+
skippedCount += await pushTestCasesForBlock(client, resolvedAppId, "script", blockId, scriptName, configDir, syncState, options.dryRun, changes, pushMaps, { force: options.force });
|
|
5468
|
+
}
|
|
5469
|
+
}
|
|
4265
5470
|
divider();
|
|
4266
5471
|
// Issue #813: a gate failure surfaced by the validate-first pass (which
|
|
4267
5472
|
// also runs in dry-run mode via the server's dry-run gate endpoints).
|
|
@@ -4499,12 +5704,45 @@ Directory Structure:
|
|
|
4499
5704
|
localScripts.set(name, readFileSync(join(transformsDirPath, file), "utf-8"));
|
|
4500
5705
|
}
|
|
4501
5706
|
}
|
|
5707
|
+
// Config vars — issue #1423. Before this, `sync diff` ignored vars
|
|
5708
|
+
// entirely, so an add/remove/value-drift between the local `vars.toml`
|
|
5709
|
+
// and the server silently read as no difference right before a push
|
|
5710
|
+
// would change or unset them. Fetch the remote vars (value included —
|
|
5711
|
+
// vars are non-secret) and read the local `vars.toml` so the comparison
|
|
5712
|
+
// below can report added/removed/modified vars.
|
|
5713
|
+
let remoteVarItemsDiff = [];
|
|
5714
|
+
try {
|
|
5715
|
+
remoteVarItemsDiff = await client.listAppConfigVars(resolvedAppId);
|
|
5716
|
+
}
|
|
5717
|
+
catch {
|
|
5718
|
+
// Older server without the vars route — treat as no remote vars.
|
|
5719
|
+
}
|
|
5720
|
+
const remoteVars = new Map(remoteVarItemsDiff
|
|
5721
|
+
.filter((v) => typeof v?.key === "string")
|
|
5722
|
+
.map((v) => [v.key, String(v.value ?? "")]));
|
|
5723
|
+
const localVars = new Map();
|
|
5724
|
+
const varsTomlPathDiff = join(configDir, "vars.toml");
|
|
5725
|
+
if (existsSync(varsTomlPathDiff)) {
|
|
5726
|
+
const parsed = parseTomlFile(varsTomlPathDiff);
|
|
5727
|
+
for (const [key, rawValue] of Object.entries(parsed)) {
|
|
5728
|
+
if (typeof rawValue === "string")
|
|
5729
|
+
localVars.set(key, rawValue);
|
|
5730
|
+
}
|
|
5731
|
+
}
|
|
4502
5732
|
// Compare
|
|
4503
5733
|
const differences = [];
|
|
5734
|
+
// Issue #1659: a local file with no server entity means one of two very
|
|
5735
|
+
// different things, and the operator needs to tell them apart. If a
|
|
5736
|
+
// prior pull managed the key, the entity was deleted server-side and
|
|
5737
|
+
// this stale file will RE-create it on the next push.
|
|
5738
|
+
// If it was never pulled, the operator authored it and push creating it
|
|
5739
|
+
// is the point. Prior sync state is the discriminator, exactly as it is
|
|
5740
|
+
// for the pull-side prune.
|
|
5741
|
+
const localOnlyStatus = (slot, key) => classifyLocalOnly(syncState?.entities, slot, key);
|
|
4504
5742
|
// Integrations
|
|
4505
5743
|
for (const key of localIntegrations) {
|
|
4506
5744
|
if (!remoteIntegrations.has(key)) {
|
|
4507
|
-
differences.push({ type: "integration", key, status: "
|
|
5745
|
+
differences.push({ type: "integration", key, status: localOnlyStatus("integrations", key) });
|
|
4508
5746
|
}
|
|
4509
5747
|
else {
|
|
4510
5748
|
differences.push({ type: "integration", key, status: "exists" });
|
|
@@ -4518,7 +5756,7 @@ Directory Structure:
|
|
|
4518
5756
|
// Webhooks
|
|
4519
5757
|
for (const key of localWebhooks) {
|
|
4520
5758
|
if (!remoteWebhooks.has(key)) {
|
|
4521
|
-
differences.push({ type: "webhook", key, status: "
|
|
5759
|
+
differences.push({ type: "webhook", key, status: localOnlyStatus("webhooks", key) });
|
|
4522
5760
|
}
|
|
4523
5761
|
else {
|
|
4524
5762
|
differences.push({ type: "webhook", key, status: "exists" });
|
|
@@ -4532,7 +5770,7 @@ Directory Structure:
|
|
|
4532
5770
|
// Cron Triggers
|
|
4533
5771
|
for (const key of localCronTriggers) {
|
|
4534
5772
|
if (!remoteCronTriggers.has(key)) {
|
|
4535
|
-
differences.push({ type: "cron-trigger", key, status: "
|
|
5773
|
+
differences.push({ type: "cron-trigger", key, status: localOnlyStatus("cronTriggers", key) });
|
|
4536
5774
|
}
|
|
4537
5775
|
else {
|
|
4538
5776
|
differences.push({ type: "cron-trigger", key, status: "exists" });
|
|
@@ -4546,7 +5784,7 @@ Directory Structure:
|
|
|
4546
5784
|
// Blob Buckets
|
|
4547
5785
|
for (const key of localBlobBuckets) {
|
|
4548
5786
|
if (!remoteBlobBuckets.has(key)) {
|
|
4549
|
-
differences.push({ type: "blob-bucket", key, status: "
|
|
5787
|
+
differences.push({ type: "blob-bucket", key, status: localOnlyStatus("blobBuckets", key) });
|
|
4550
5788
|
}
|
|
4551
5789
|
else {
|
|
4552
5790
|
differences.push({ type: "blob-bucket", key, status: "exists" });
|
|
@@ -4560,7 +5798,7 @@ Directory Structure:
|
|
|
4560
5798
|
// Prompts
|
|
4561
5799
|
for (const key of localPrompts) {
|
|
4562
5800
|
if (!remotePrompts.has(key)) {
|
|
4563
|
-
differences.push({ type: "prompt", key, status: "
|
|
5801
|
+
differences.push({ type: "prompt", key, status: localOnlyStatus("prompts", key) });
|
|
4564
5802
|
}
|
|
4565
5803
|
else {
|
|
4566
5804
|
differences.push({ type: "prompt", key, status: "exists" });
|
|
@@ -4592,7 +5830,7 @@ Directory Structure:
|
|
|
4592
5830
|
const remoteWorkflowIds = new Map(workflowItems.map((w) => [w.workflowKey, w.workflowId]));
|
|
4593
5831
|
for (const key of localWorkflows) {
|
|
4594
5832
|
if (!remoteWorkflows.has(key)) {
|
|
4595
|
-
differences.push({ type: "workflow", key, status: "
|
|
5833
|
+
differences.push({ type: "workflow", key, status: localOnlyStatus("workflows", key) });
|
|
4596
5834
|
continue;
|
|
4597
5835
|
}
|
|
4598
5836
|
// Present on both sides → compare content.
|
|
@@ -4643,7 +5881,7 @@ Directory Structure:
|
|
|
4643
5881
|
// Email Templates
|
|
4644
5882
|
for (const key of localEmailTemplates) {
|
|
4645
5883
|
if (!remoteEmailTemplates.has(key)) {
|
|
4646
|
-
differences.push({ type: "email-template", key, status: "
|
|
5884
|
+
differences.push({ type: "email-template", key, status: localOnlyStatus("emailTemplates", key) });
|
|
4647
5885
|
}
|
|
4648
5886
|
else {
|
|
4649
5887
|
differences.push({ type: "email-template", key, status: "exists" });
|
|
@@ -4664,7 +5902,7 @@ Directory Structure:
|
|
|
4664
5902
|
for (const [name, localBody] of localScripts) {
|
|
4665
5903
|
const remote = remoteScripts.get(name);
|
|
4666
5904
|
if (!remote) {
|
|
4667
|
-
differences.push({ type: "transform", key: name, status: "
|
|
5905
|
+
differences.push({ type: "transform", key: name, status: localOnlyStatus("scripts", name) });
|
|
4668
5906
|
continue;
|
|
4669
5907
|
}
|
|
4670
5908
|
let status = "exists";
|
|
@@ -4703,6 +5941,10 @@ Directory Structure:
|
|
|
4703
5941
|
differences.push({ type: "transform", key: name, status: "remote only" });
|
|
4704
5942
|
}
|
|
4705
5943
|
}
|
|
5944
|
+
// Config vars — issue #1423 (see `diffVars`).
|
|
5945
|
+
for (const row of diffVars(localVars, remoteVars)) {
|
|
5946
|
+
differences.push(row);
|
|
5947
|
+
}
|
|
4706
5948
|
// Compare test cases for synced prompts and workflows
|
|
4707
5949
|
const testCaseDiffs = [];
|
|
4708
5950
|
// Helper to compare test cases for a block
|
|
@@ -4727,7 +5969,7 @@ Directory Structure:
|
|
|
4727
5969
|
}
|
|
4728
5970
|
for (const slug of localTestSlugs) {
|
|
4729
5971
|
if (!remoteTestNames.has(slug)) {
|
|
4730
|
-
testCaseDiffs.push({ blockType, blockKey, slug, status:
|
|
5972
|
+
testCaseDiffs.push({ blockType, blockKey, slug, status: LOCAL_ONLY_NEW });
|
|
4731
5973
|
}
|
|
4732
5974
|
else {
|
|
4733
5975
|
testCaseDiffs.push({ blockType, blockKey, slug, status: "exists" });
|
|
@@ -4751,17 +5993,29 @@ Directory Structure:
|
|
|
4751
5993
|
}
|
|
4752
5994
|
}
|
|
4753
5995
|
divider();
|
|
4754
|
-
const localOnly = differences.filter((d) => d.status ===
|
|
5996
|
+
const localOnly = differences.filter((d) => d.status === LOCAL_ONLY_NEW);
|
|
5997
|
+
const localOnlyAbsent = differences.filter((d) => d.status === LOCAL_ONLY_ABSENT);
|
|
4755
5998
|
const remoteOnly = differences.filter((d) => d.status === "remote only");
|
|
4756
5999
|
const modified = differences.filter((d) => d.status === "modified");
|
|
4757
6000
|
const existing = differences.filter((d) => d.status === "exists");
|
|
4758
6001
|
if (localOnly.length > 0) {
|
|
4759
|
-
info("Local only (will be created on push):");
|
|
6002
|
+
info("Local only — new (will be created on push):");
|
|
4760
6003
|
for (const d of localOnly) {
|
|
4761
6004
|
console.log(` ${chalk.green("+")} ${d.type}: ${d.key}`);
|
|
4762
6005
|
}
|
|
4763
6006
|
console.log();
|
|
4764
6007
|
}
|
|
6008
|
+
// #1659: these files were written by a prior pull, so their entity was
|
|
6009
|
+
// deleted server-side afterwards. `sync pull` prunes them by
|
|
6010
|
+
// default; they only survive under `--no-prune` or a skipped prune, and
|
|
6011
|
+
// while they sit here the next push re-creates what was deleted.
|
|
6012
|
+
if (localOnlyAbsent.length > 0) {
|
|
6013
|
+
warn(`Local only — ${ABSENT_FROM_EXPORT}; push will RE-create these unless you remove them:`);
|
|
6014
|
+
for (const d of localOnlyAbsent) {
|
|
6015
|
+
console.log(` ${chalk.yellow("!")} ${d.type}: ${d.key}`);
|
|
6016
|
+
}
|
|
6017
|
+
console.log();
|
|
6018
|
+
}
|
|
4765
6019
|
if (remoteOnly.length > 0) {
|
|
4766
6020
|
warn("Remote only (not in local config):");
|
|
4767
6021
|
for (const d of remoteOnly) {
|
|
@@ -4788,7 +6042,7 @@ Directory Structure:
|
|
|
4788
6042
|
}
|
|
4789
6043
|
}
|
|
4790
6044
|
// Show test case differences
|
|
4791
|
-
const tcLocalOnly = testCaseDiffs.filter((d) => d.status ===
|
|
6045
|
+
const tcLocalOnly = testCaseDiffs.filter((d) => d.status === LOCAL_ONLY_NEW);
|
|
4792
6046
|
const tcRemoteOnly = testCaseDiffs.filter((d) => d.status === "remote only");
|
|
4793
6047
|
const tcSynced = testCaseDiffs.filter((d) => d.status === "exists");
|
|
4794
6048
|
if (tcLocalOnly.length > 0 || tcRemoteOnly.length > 0) {
|
|
@@ -4805,7 +6059,8 @@ Directory Structure:
|
|
|
4805
6059
|
}
|
|
4806
6060
|
}
|
|
4807
6061
|
divider();
|
|
4808
|
-
keyValue("Local only", localOnly.length);
|
|
6062
|
+
keyValue("Local only (new)", localOnly.length);
|
|
6063
|
+
keyValue("Local only (absent from export)", localOnlyAbsent.length);
|
|
4809
6064
|
keyValue("Remote only", remoteOnly.length);
|
|
4810
6065
|
keyValue("Modified", modified.length);
|
|
4811
6066
|
keyValue("Synced", existing.length);
|
|
@@ -4823,11 +6078,15 @@ Directory Structure:
|
|
|
4823
6078
|
// Migrate-toml (issue #752): bulk-rewrite database-type TOML files from
|
|
4824
6079
|
// the legacy JSON-string form for `definition`/`params` to the native
|
|
4825
6080
|
// nested-table form. Idempotent: ops already in native form are left as
|
|
4826
|
-
// is;
|
|
4827
|
-
//
|
|
6081
|
+
// is; the round-trip serializes with smol-toml (TOML 1.0, issue #1450), so
|
|
6082
|
+
// heterogeneous arrays now converge to native form too — only shapes that
|
|
6083
|
+
// TOML can't represent without data loss (null / undefined / an array
|
|
6084
|
+
// containing null) fall back to JSON-string per field with a log message.
|
|
4828
6085
|
sync
|
|
4829
6086
|
.command("migrate-toml")
|
|
4830
|
-
.description("Rewrite database-type TOML files to native
|
|
6087
|
+
.description("Rewrite database-type and workflow TOML files to native form " +
|
|
6088
|
+
"([operations.definition] / [[operations.params]] for database types; " +
|
|
6089
|
+
"[workflow.inputSchema] / [workflow.outputSchema] for workflows)")
|
|
4831
6090
|
.argument("[app-id]", "App ID (uses current app if not specified)")
|
|
4832
6091
|
.option("--app <app-id>", "App ID")
|
|
4833
6092
|
.option("--dir <path>", "Config directory (overrides the auto-resolved per-env path)")
|
|
@@ -4846,83 +6105,122 @@ Directory Structure:
|
|
|
4846
6105
|
process.exit(1);
|
|
4847
6106
|
}
|
|
4848
6107
|
const dbTypesDir = join(configDir, "database-types");
|
|
4849
|
-
|
|
4850
|
-
|
|
4851
|
-
|
|
4852
|
-
|
|
4853
|
-
const
|
|
4854
|
-
|
|
4855
|
-
|
|
6108
|
+
const workflowsDir = join(configDir, "workflows");
|
|
6109
|
+
const dbFiles = existsSync(dbTypesDir)
|
|
6110
|
+
? readdirSync(dbTypesDir).filter((f) => f.endsWith(".toml"))
|
|
6111
|
+
: [];
|
|
6112
|
+
const workflowFiles = existsSync(workflowsDir)
|
|
6113
|
+
? readdirSync(workflowsDir).filter((f) => f.endsWith(".toml"))
|
|
6114
|
+
: [];
|
|
6115
|
+
// Restructured early-return (issue #1446): don't bail just because
|
|
6116
|
+
// `database-types/` is absent — a workflow-only project still has
|
|
6117
|
+
// `inputSchema`/`outputSchema` to migrate.
|
|
6118
|
+
if (dbFiles.length === 0 && workflowFiles.length === 0) {
|
|
6119
|
+
info("No database-type or workflow TOML files found; nothing to migrate.");
|
|
4856
6120
|
return;
|
|
4857
6121
|
}
|
|
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
6122
|
let migratedCount = 0;
|
|
4881
6123
|
let unchangedCount = 0;
|
|
4882
|
-
|
|
4883
|
-
|
|
4884
|
-
|
|
4885
|
-
|
|
4886
|
-
//
|
|
4887
|
-
//
|
|
4888
|
-
|
|
4889
|
-
|
|
4890
|
-
|
|
4891
|
-
|
|
4892
|
-
|
|
4893
|
-
|
|
6124
|
+
// ---- database-types/ pass ----
|
|
6125
|
+
if (dbFiles.length > 0) {
|
|
6126
|
+
// Hydrate ruleSetIdToName from local sync state so files that
|
|
6127
|
+
// reference a rule set via the legacy `ruleSetId = "01..."` form
|
|
6128
|
+
// round-trip with the correct `ruleSetName`. Without this, any user
|
|
6129
|
+
// who runs migrate-toml against a file with a rule-set assignment
|
|
6130
|
+
// would silently lose that reference (review feedback r3246633010).
|
|
6131
|
+
//
|
|
6132
|
+
// Files that use the modern key-based `ruleSetName = "..."` form
|
|
6133
|
+
// don't need the map at all — `parseDatabaseTypeToml` stores the
|
|
6134
|
+
// value in `typeConfig._ruleSetName` and the serializer now prefers
|
|
6135
|
+
// that. The map is only load-bearing for the legacy ID-based form.
|
|
6136
|
+
const ruleSetIdToName = new Map();
|
|
6137
|
+
const migrateSyncState = loadSyncState(configDir);
|
|
6138
|
+
if (migrateSyncState?.entities?.ruleSets) {
|
|
6139
|
+
for (const [fileKey, entry] of Object.entries(migrateSyncState.entities.ruleSets)) {
|
|
6140
|
+
if (entry && typeof entry === "object" && "id" in entry && entry.id) {
|
|
6141
|
+
// fileKey is the sanitized rule-set name (see sync pull at
|
|
6142
|
+
// sync.ts:1385). It's the same shape the server returned and
|
|
6143
|
+
// matches what a TOML file's ruleSetName field would carry.
|
|
6144
|
+
ruleSetIdToName.set(entry.id, fileKey);
|
|
6145
|
+
}
|
|
6146
|
+
}
|
|
4894
6147
|
}
|
|
4895
|
-
|
|
4896
|
-
|
|
4897
|
-
|
|
4898
|
-
|
|
4899
|
-
|
|
6148
|
+
for (const file of dbFiles) {
|
|
6149
|
+
const filePath = join(dbTypesDir, file);
|
|
6150
|
+
const rawBefore = readFileSync(filePath, "utf-8");
|
|
6151
|
+
const tomlData = parseTomlFile(filePath);
|
|
6152
|
+
// `accessRule`/`access` conflicts surface as a clear error here too,
|
|
6153
|
+
// rather than silently dropping the field during a migrate rewrite.
|
|
6154
|
+
let typeConfig;
|
|
6155
|
+
let operations;
|
|
6156
|
+
let subscriptions;
|
|
6157
|
+
try {
|
|
6158
|
+
({ typeConfig, operations, subscriptions } =
|
|
6159
|
+
parseDatabaseTypeToml(tomlData));
|
|
6160
|
+
}
|
|
6161
|
+
catch (err) {
|
|
6162
|
+
if (err instanceof SubscriptionAccessKeyConflictError) {
|
|
6163
|
+
error(` ${file}: ${err.message}`);
|
|
6164
|
+
error(`Aborting migrate-toml: subscription \`access\`/\`accessRule\` conflict in ${file}.`);
|
|
6165
|
+
process.exit(1);
|
|
6166
|
+
}
|
|
6167
|
+
throw err;
|
|
6168
|
+
}
|
|
6169
|
+
// Force-native: ignore existing hints so every TOMLable field
|
|
6170
|
+
// ends up in nested-table form. Subscriptions are preserved on the
|
|
6171
|
+
// rewrite (issue #803) so migrate-toml never drops them.
|
|
6172
|
+
const rewritten = serializeDatabaseType(typeConfig, operations, ruleSetIdToName, {
|
|
6173
|
+
defaultForm: "native",
|
|
6174
|
+
logger: (msg) => info(` ${msg}`),
|
|
6175
|
+
subscriptions,
|
|
6176
|
+
});
|
|
6177
|
+
if (rewritten === rawBefore) {
|
|
6178
|
+
unchangedCount++;
|
|
6179
|
+
continue;
|
|
6180
|
+
}
|
|
6181
|
+
if (options.dryRun) {
|
|
6182
|
+
info(`Would migrate database-types/${file}`);
|
|
4900
6183
|
}
|
|
4901
|
-
|
|
6184
|
+
else {
|
|
6185
|
+
writeFileSync(filePath, rewritten);
|
|
6186
|
+
info(`Migrated database-types/${file}`);
|
|
6187
|
+
// Update content hash in sync state if we have an entry for it.
|
|
6188
|
+
const syncState = loadSyncState(configDir);
|
|
6189
|
+
const dbType = typeConfig.databaseType || basename(file, ".toml");
|
|
6190
|
+
if (syncState?.entities?.databaseTypes?.[dbType]) {
|
|
6191
|
+
syncState.entities.databaseTypes[dbType].contentHash = computeFileHash(filePath);
|
|
6192
|
+
saveSyncState(configDir, syncState);
|
|
6193
|
+
}
|
|
6194
|
+
}
|
|
6195
|
+
migratedCount++;
|
|
4902
6196
|
}
|
|
4903
|
-
|
|
4904
|
-
|
|
4905
|
-
|
|
4906
|
-
|
|
4907
|
-
|
|
4908
|
-
|
|
4909
|
-
|
|
4910
|
-
|
|
4911
|
-
|
|
6197
|
+
}
|
|
6198
|
+
// ---- workflows/ pass (issue #1446) ----
|
|
6199
|
+
// Convert string-form `inputSchema`/`outputSchema` to native tables.
|
|
6200
|
+
// Purely local, mirroring the database-types pass. The stored workflow
|
|
6201
|
+
// hash uses `computeExpandedContentHash` (parse then hash), NOT
|
|
6202
|
+
// `computeFileHash`, matching the workflow convention on the pull/push
|
|
6203
|
+
// paths — otherwise a push after migrate would spuriously re-push.
|
|
6204
|
+
for (const file of workflowFiles) {
|
|
6205
|
+
const filePath = join(workflowsDir, file);
|
|
6206
|
+
const rawBefore = readFileSync(filePath, "utf-8");
|
|
6207
|
+
const workflowKey = basename(file, ".toml");
|
|
6208
|
+
const { content: rewritten, changed } = rewriteWorkflowSchemasToNative(rawBefore, workflowKey, (msg) => info(` ${msg}`));
|
|
6209
|
+
if (!changed) {
|
|
4912
6210
|
unchangedCount++;
|
|
4913
6211
|
continue;
|
|
4914
6212
|
}
|
|
4915
6213
|
if (options.dryRun) {
|
|
4916
|
-
info(`Would migrate
|
|
6214
|
+
info(`Would migrate workflows/${file}`);
|
|
4917
6215
|
}
|
|
4918
6216
|
else {
|
|
4919
6217
|
writeFileSync(filePath, rewritten);
|
|
4920
|
-
info(`Migrated
|
|
4921
|
-
// Update content hash
|
|
6218
|
+
info(`Migrated workflows/${file}`);
|
|
6219
|
+
// Update the stored content hash so a subsequent push sees a no-op.
|
|
4922
6220
|
const syncState = loadSyncState(configDir);
|
|
4923
|
-
|
|
4924
|
-
|
|
4925
|
-
|
|
6221
|
+
if (syncState?.entities?.workflows?.[workflowKey]) {
|
|
6222
|
+
syncState.entities.workflows[workflowKey].contentHash =
|
|
6223
|
+
computeExpandedContentHash(parseTomlFile(filePath));
|
|
4926
6224
|
saveSyncState(configDir, syncState);
|
|
4927
6225
|
}
|
|
4928
6226
|
}
|
|
@@ -4938,6 +6236,63 @@ Directory Structure:
|
|
|
4938
6236
|
success(`Rewrote ${migratedCount} TOML file(s) to native form.`);
|
|
4939
6237
|
}
|
|
4940
6238
|
});
|
|
6239
|
+
// migrate-v2 — convert a legacy sync directory into the unified block
|
|
6240
|
+
// layout `blocks/<type>/<key>/block.toml` (issue #1183, Phase 3). Purely
|
|
6241
|
+
// local: it reads already-pulled legacy files and writes the v2 tree. The
|
|
6242
|
+
// legacy files are left in place (legacy stays the default until v2 is
|
|
6243
|
+
// proven), so re-running push/pull is unaffected.
|
|
6244
|
+
sync
|
|
6245
|
+
.command("migrate-v2")
|
|
6246
|
+
.description("Convert legacy per-type sync files (prompts/, integrations/, workflows/, transforms/) into the v2 blocks/<type>/<key>/block.toml layout")
|
|
6247
|
+
.argument("[app-id]", "App ID (uses current app if not specified)")
|
|
6248
|
+
.option("--app <app-id>", "App ID")
|
|
6249
|
+
.option("--dir <path>", "Config directory (overrides the auto-resolved per-env path)")
|
|
6250
|
+
.option("--dry-run", "Show what would change without writing files")
|
|
6251
|
+
.option("--force", "Overwrite existing block.toml files instead of skipping them")
|
|
6252
|
+
.action(async (appId, options) => {
|
|
6253
|
+
// Resolve appId so we land in the per-env sync dir even when the user
|
|
6254
|
+
// didn't pass `--dir`. The app ID itself isn't used for any API calls —
|
|
6255
|
+
// migrate-v2 is a purely local rewrite (like migrate-toml).
|
|
6256
|
+
const resolvedAppId = resolveAppId(appId, options);
|
|
6257
|
+
const configDir = resolveSyncDir({ appId: resolvedAppId, userDir: options.dir });
|
|
6258
|
+
if (isAutoResolvedSyncDir(options.dir)) {
|
|
6259
|
+
info(`Using per-environment sync directory: ${configDir}`);
|
|
6260
|
+
}
|
|
6261
|
+
if (!existsSync(configDir)) {
|
|
6262
|
+
error(`Config directory not found: ${configDir}`);
|
|
6263
|
+
process.exit(1);
|
|
6264
|
+
}
|
|
6265
|
+
const before = detectLayout(configDir);
|
|
6266
|
+
if (!before.legacy) {
|
|
6267
|
+
info("No legacy block files found (prompts/, integrations/, workflows/, transforms/); nothing to migrate.");
|
|
6268
|
+
return;
|
|
6269
|
+
}
|
|
6270
|
+
const result = migrateLegacyToV2(configDir, {
|
|
6271
|
+
dryRun: options.dryRun,
|
|
6272
|
+
force: options.force,
|
|
6273
|
+
});
|
|
6274
|
+
for (const b of result.blocks) {
|
|
6275
|
+
const suffix = b.testsCopied > 0 ? ` (+${b.testsCopied} test case(s))` : "";
|
|
6276
|
+
if (options.dryRun) {
|
|
6277
|
+
info(`Would write blocks/${b.type === "script" ? "scripts" : b.type + "s"}/${b.key}/block.toml${suffix}`);
|
|
6278
|
+
}
|
|
6279
|
+
else {
|
|
6280
|
+
info(`Wrote blocks/${b.type === "script" ? "scripts" : b.type + "s"}/${b.key}/block.toml${suffix}`);
|
|
6281
|
+
}
|
|
6282
|
+
}
|
|
6283
|
+
for (const s of result.skipped) {
|
|
6284
|
+
warn(`Skipped ${s.type}/${s.key}: ${s.reason}`);
|
|
6285
|
+
}
|
|
6286
|
+
divider();
|
|
6287
|
+
keyValue("Migrated", result.blocks.length);
|
|
6288
|
+
keyValue("Skipped", result.skipped.length);
|
|
6289
|
+
if (options.dryRun) {
|
|
6290
|
+
info("Dry-run only — no files were modified.");
|
|
6291
|
+
}
|
|
6292
|
+
else if (result.blocks.length > 0) {
|
|
6293
|
+
success(`Converted ${result.blocks.length} block(s) to the v2 layout. Legacy files were left in place.`);
|
|
6294
|
+
}
|
|
6295
|
+
});
|
|
4941
6296
|
// Revert — restore a pre-pull snapshot (issue #578, Phase 1).
|
|
4942
6297
|
sync
|
|
4943
6298
|
.command("revert")
|
|
@@ -5032,24 +6387,4 @@ Directory Structure:
|
|
|
5032
6387
|
info("Run 'primitive sync diff' to inspect the restored state versus the server.");
|
|
5033
6388
|
});
|
|
5034
6389
|
}
|
|
5035
|
-
/**
|
|
5036
|
-
* Best-effort check for uncommitted git changes under `dir`. Used only to warn
|
|
5037
|
-
* before a revert overwrites local edits — never fatal. Returns false if git
|
|
5038
|
-
* isn't available, the dir isn't in a repo, or anything goes wrong.
|
|
5039
|
-
*/
|
|
5040
|
-
async function hasUncommittedChanges(dir) {
|
|
5041
|
-
if (!existsSync(dir))
|
|
5042
|
-
return false;
|
|
5043
|
-
try {
|
|
5044
|
-
const { execSync } = await import("child_process");
|
|
5045
|
-
const out = execSync(`git status --porcelain -- "${dir}"`, {
|
|
5046
|
-
stdio: ["ignore", "pipe", "ignore"],
|
|
5047
|
-
encoding: "utf-8",
|
|
5048
|
-
});
|
|
5049
|
-
return out.trim().length > 0;
|
|
5050
|
-
}
|
|
5051
|
-
catch {
|
|
5052
|
-
return false;
|
|
5053
|
-
}
|
|
5054
|
-
}
|
|
5055
6390
|
//# sourceMappingURL=sync.js.map
|