primitive-admin 1.1.0-alpha.46 → 1.1.0-alpha.48

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.
Files changed (78) hide show
  1. package/dist/bin/primitive.js +2 -0
  2. package/dist/bin/primitive.js.map +1 -1
  3. package/dist/src/commands/apps.js +54 -2
  4. package/dist/src/commands/apps.js.map +1 -1
  5. package/dist/src/commands/databases.js +31 -10
  6. package/dist/src/commands/databases.js.map +1 -1
  7. package/dist/src/commands/documents.js +77 -0
  8. package/dist/src/commands/documents.js.map +1 -1
  9. package/dist/src/commands/groups.js +9 -4
  10. package/dist/src/commands/groups.js.map +1 -1
  11. package/dist/src/commands/metadata.js +150 -0
  12. package/dist/src/commands/metadata.js.map +1 -1
  13. package/dist/src/commands/settings.d.ts +15 -0
  14. package/dist/src/commands/settings.js +102 -0
  15. package/dist/src/commands/settings.js.map +1 -0
  16. package/dist/src/commands/sync-app-settings.d.ts +105 -0
  17. package/dist/src/commands/sync-app-settings.js +339 -0
  18. package/dist/src/commands/sync-app-settings.js.map +1 -0
  19. package/dist/src/commands/sync.d.ts +169 -19
  20. package/dist/src/commands/sync.js +942 -190
  21. package/dist/src/commands/sync.js.map +1 -1
  22. package/dist/src/commands/webhooks.js +10 -1
  23. package/dist/src/commands/webhooks.js.map +1 -1
  24. package/dist/src/commands/workflows.d.ts +30 -0
  25. package/dist/src/commands/workflows.js +305 -12
  26. package/dist/src/commands/workflows.js.map +1 -1
  27. package/dist/src/lib/api-client.d.ts +33 -2
  28. package/dist/src/lib/api-client.js +52 -7
  29. package/dist/src/lib/api-client.js.map +1 -1
  30. package/dist/src/lib/app-settings-descriptor.d.ts +108 -0
  31. package/dist/src/lib/app-settings-descriptor.js +250 -0
  32. package/dist/src/lib/app-settings-descriptor.js.map +1 -0
  33. package/dist/src/lib/codegen-shared/generatedFiles.d.ts +15 -0
  34. package/dist/src/lib/codegen-shared/generatedFiles.js +27 -10
  35. package/dist/src/lib/codegen-shared/generatedFiles.js.map +1 -1
  36. package/dist/src/lib/db-codegen/dbGenerator.d.ts +11 -83
  37. package/dist/src/lib/db-codegen/dbGenerator.js +93 -556
  38. package/dist/src/lib/db-codegen/dbGenerator.js.map +1 -1
  39. package/dist/src/lib/db-codegen/dbNaming.d.ts +15 -7
  40. package/dist/src/lib/db-codegen/dbNaming.js +25 -13
  41. package/dist/src/lib/db-codegen/dbNaming.js.map +1 -1
  42. package/dist/src/lib/db-codegen/dbTemplates.d.ts +55 -4
  43. package/dist/src/lib/db-codegen/dbTemplates.js +169 -40
  44. package/dist/src/lib/db-codegen/dbTemplates.js.map +1 -1
  45. package/dist/src/lib/db-codegen/dbTypeIR.d.ts +146 -0
  46. package/dist/src/lib/db-codegen/dbTypeIR.js +517 -0
  47. package/dist/src/lib/db-codegen/dbTypeIR.js.map +1 -0
  48. package/dist/src/lib/db-codegen/generated-operation-def-descriptor.d.ts +101 -0
  49. package/dist/src/lib/db-codegen/generated-operation-def-descriptor.js +200 -0
  50. package/dist/src/lib/db-codegen/generated-operation-def-descriptor.js.map +1 -0
  51. package/dist/src/lib/generated-allowlist.js +10 -0
  52. package/dist/src/lib/generated-allowlist.js.map +1 -1
  53. package/dist/src/lib/swift-codegen/dbGenerator.d.ts +68 -0
  54. package/dist/src/lib/swift-codegen/dbGenerator.js +380 -0
  55. package/dist/src/lib/swift-codegen/dbGenerator.js.map +1 -0
  56. package/dist/src/lib/swift-codegen/dbSwiftTypes.d.ts +42 -0
  57. package/dist/src/lib/swift-codegen/dbSwiftTypes.js +100 -0
  58. package/dist/src/lib/swift-codegen/dbSwiftTypes.js.map +1 -0
  59. package/dist/src/lib/swift-codegen/generator.d.ts +84 -0
  60. package/dist/src/lib/swift-codegen/generator.js +178 -0
  61. package/dist/src/lib/swift-codegen/generator.js.map +1 -0
  62. package/dist/src/lib/swift-codegen/schemaToSwift.d.ts +72 -0
  63. package/dist/src/lib/swift-codegen/schemaToSwift.js +644 -0
  64. package/dist/src/lib/swift-codegen/schemaToSwift.js.map +1 -0
  65. package/dist/src/lib/swift-codegen/swiftNaming.d.ts +85 -0
  66. package/dist/src/lib/swift-codegen/swiftNaming.js +198 -0
  67. package/dist/src/lib/swift-codegen/swiftNaming.js.map +1 -0
  68. package/dist/src/lib/sync-resource-types.d.ts +225 -0
  69. package/dist/src/lib/sync-resource-types.js +394 -0
  70. package/dist/src/lib/sync-resource-types.js.map +1 -0
  71. package/dist/src/lib/workflow-codegen/generator.d.ts +18 -5
  72. package/dist/src/lib/workflow-codegen/generator.js +116 -41
  73. package/dist/src/lib/workflow-codegen/generator.js.map +1 -1
  74. package/dist/src/lib/workflow-codegen/invokerIR.d.ts +85 -0
  75. package/dist/src/lib/workflow-codegen/invokerIR.js +71 -0
  76. package/dist/src/lib/workflow-codegen/invokerIR.js.map +1 -0
  77. package/dist/src/types/index.d.ts +4 -0
  78. package/package.json +4 -2
@@ -13,9 +13,12 @@ import { createSnapshot, listSnapshots, resolveSnapshot, restoreSnapshot, pruneS
13
13
  import { validateWorkflowToml, formatWorkflowTomlErrors, } from "../lib/workflow-toml-validator.js";
14
14
  import { expandWorkflowTomlData } from "../lib/workflow-fragments.js";
15
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";
16
18
  import { applyWorkflowBody } from "../lib/workflow-apply.js";
17
19
  import { buildWorkflowPayloadFromToml } from "../lib/workflow-payload.js";
18
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";
19
22
  import { success, error, printApiError, info, warn, keyValue, json, divider, } from "../lib/output.js";
20
23
  import { confirmPrompt } from "../lib/confirm-prompt.js";
21
24
  import chalk from "chalk";
@@ -238,108 +241,340 @@ export function hashRemoteWorkflowForDiff(workflow, draft, configs) {
238
241
  const parsed = parseConfigToml(serialized);
239
242
  return hashWorkflowTomlForDiff(parsed);
240
243
  }
241
- // Boolean [auth] keys in app.toml that map 1:1 to app-settings fields.
242
- const AUTH_BOOLEAN_KEYS = [
243
- "googleOAuthEnabled",
244
- "passkeyEnabled",
245
- "magicLinkEnabled",
246
- "appleSignInEnabled",
247
- "otpEnabled",
248
- ];
249
- // Every recognized [auth] key. Single source of truth shared by pull
250
- // serialization, push parsing, and the unrecognized-key warning so the three
251
- // never drift apart. `appleAudiences` is a string array and `passkeys` is the
252
- // passkey RP config object; the rest are booleans.
253
- const RECOGNIZED_AUTH_KEYS = new Set([
254
- ...AUTH_BOOLEAN_KEYS,
255
- "appleAudiences",
256
- "passkeys",
257
- ]);
258
244
  /**
259
245
  * Build the [auth] block of app.toml from server settings (pull direction).
260
246
  *
261
- * Apple/OTP fields round-trip alongside the legacy google/passkey/magic-link
262
- * keys. Two asymmetries are handled deliberately:
263
- * - `appleSignInEnabled` comes back as `boolean | null`; smol-toml throws on
264
- * null, so an unset value (null) is omitted rather than written.
265
- * - `appleAudiences` is omitted when empty. An empty array and an omitted key
266
- * both mean "no audiences" (the server normalizes [] -> null), so omitting
267
- * an empty list on pull is not lossy.
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.
268
253
  */
269
254
  export function serializeAuthBlock(settings) {
270
- const auth = {
271
- googleOAuthEnabled: settings.googleOAuthEnabled,
272
- passkeyEnabled: settings.passkeyEnabled,
273
- magicLinkEnabled: settings.magicLinkEnabled,
274
- };
275
- if (typeof settings.appleSignInEnabled === "boolean") {
276
- auth.appleSignInEnabled = settings.appleSignInEnabled;
277
- }
278
- if (Array.isArray(settings.appleAudiences) &&
279
- settings.appleAudiences.length > 0) {
280
- auth.appleAudiences = settings.appleAudiences;
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 ?? "");
281
292
  }
282
- if (typeof settings.otpEnabled === "boolean") {
283
- auth.otpEnabled = settings.otpEnabled;
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 };
284
321
  }
285
- if (settings.passkeyRpConfig) {
286
- auth.passkeys = settings.passkeyRpConfig;
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 ?? "")),
329
+ };
287
330
  }
288
- return auth;
331
+ return { write: true, content: serializeVars(outcome.vars), varEntities };
289
332
  }
290
333
  /**
291
- * Translate the [auth] block of app.toml into app-settings fields (push
292
- * direction).
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).
293
350
  *
294
- * Only keys actually present in the TOML are forwarded an omitted key is left
295
- * untouched so it never overwrites server state (e.g. flipping a previously-true
296
- * flag to false). An explicit value, including `false`, is forwarded as-is.
297
- * `appleAudiences = []` is forwarded too; the server normalizes [] -> null
298
- * ("no audiences"). Keys outside RECOGNIZED_AUTH_KEYS produce a warning rather
299
- * than a hard error, so a typo or a future key doesn't silently drop.
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.
300
358
  */
301
- export function parseAppAuthSettings(auth) {
302
- const settings = {};
303
- const warnings = [];
304
- for (const key of AUTH_BOOLEAN_KEYS) {
305
- if (key in auth)
306
- settings[key] = auth[key];
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"}.`);
307
364
  }
308
- if ("appleAudiences" in auth) {
309
- settings.appleAudiences = auth.appleAudiences;
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
+ }
310
381
  }
311
- if ("passkeys" in auth) {
312
- settings.passkeyRpConfig = auth.passkeys;
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 });
313
472
  }
314
- for (const key of Object.keys(auth)) {
315
- if (!RECOGNIZED_AUTH_KEYS.has(key)) {
316
- warnings.push(`Unrecognized [auth] key "${key}" in app.toml ignored. ` +
317
- `Recognized keys: ${[...RECOGNIZED_AUTH_KEYS].join(", ")}.`);
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;
318
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);
319
516
  }
320
- return { settings, warnings };
517
+ return { upserts, deletions, conflicts, skippedCount };
321
518
  }
322
- // TOML serialization helpers
323
- function serializeAppSettings(settings) {
324
- const data = {
325
- app: {
326
- name: settings.name,
327
- mode: settings.mode,
328
- waitlistEnabled: settings.waitlistEnabled,
329
- baseUrl: settings.baseUrl,
330
- },
331
- auth: serializeAuthBlock(settings),
332
- };
333
- if (settings.corsMode === "custom") {
334
- data.cors = {
335
- mode: settings.corsMode,
336
- allowedOrigins: settings.corsAllowedOrigins,
337
- allowCredentials: settings.corsAllowCredentials,
338
- allowedMethods: settings.corsAllowedMethods,
339
- maxAge: settings.corsMaxAge,
340
- };
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--;
341
538
  }
342
- return stringifyConfigToml(data);
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;
343
578
  }
344
579
  function serializeIntegration(integration) {
345
580
  const data = {
@@ -1111,8 +1346,15 @@ export async function pullScripts(client, appId, configDir, logger = () => { })
1111
1346
  items = result.items || [];
1112
1347
  }
1113
1348
  catch {
1114
- return { scriptEntities, count: 0 };
1349
+ return {
1350
+ scriptEntities,
1351
+ count: 0,
1352
+ presence: { ok: false },
1353
+ listOk: false,
1354
+ serverKeys: [],
1355
+ };
1115
1356
  }
1357
+ let detailsOk = true;
1116
1358
  let written = 0;
1117
1359
  for (const script of items) {
1118
1360
  const name = script?.name;
@@ -1131,6 +1373,7 @@ export async function pullScripts(client, appId, configDir, logger = () => { })
1131
1373
  full = await client.getScript(appId, script.scriptId);
1132
1374
  }
1133
1375
  catch {
1376
+ detailsOk = false;
1134
1377
  logger(` Skipped transforms/${name}.rhai (could not fetch config)`);
1135
1378
  continue;
1136
1379
  }
@@ -1155,7 +1398,11 @@ export async function pullScripts(client, appId, configDir, logger = () => { })
1155
1398
  written += 1;
1156
1399
  logger(` Wrote transforms/${filename}`);
1157
1400
  }
1158
- return { scriptEntities, count: written };
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 };
1159
1406
  }
1160
1407
  async function pullTestCasesForBlock(client, appId, blockType, blockId, blockKey, configDir, testCaseEntities, lookupMaps) {
1161
1408
  let testCases;
@@ -1521,6 +1768,7 @@ Directory Structure:
1521
1768
  .option("--app <app-id>", "App ID")
1522
1769
  .option("--dir <path>", "Config directory (overrides the auto-resolved per-env path)")
1523
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)")
1524
1772
  .action(async (appId, options) => {
1525
1773
  const resolvedAppId = resolveAppId(appId, options);
1526
1774
  const configDir = resolveSyncDir({ appId: resolvedAppId, userDir: options.dir });
@@ -1533,14 +1781,24 @@ Directory Structure:
1533
1781
  const client = new ApiClient();
1534
1782
  info(`Pulling configuration for app ${resolvedAppId}...`);
1535
1783
  try {
1536
- // Fetch all data
1537
- const [settings, integrationItems, promptItems, workflowItems, emailTemplatesResult] = await Promise.all([
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([
1538
1793
  client.getAppSettings(resolvedAppId).catch(() => null),
1539
1794
  fetchAll((p) => client.listIntegrations(resolvedAppId, p)),
1540
1795
  fetchAll((p) => client.listPrompts(resolvedAppId, p)),
1541
1796
  fetchAll((p) => client.listWorkflows(resolvedAppId, p)),
1542
- client.listEmailTemplates(resolvedAppId).catch(() => ({ templates: [] })),
1797
+ attemptFetch(() => client.listEmailTemplates(resolvedAppId)),
1543
1798
  ]);
1799
+ const emailTemplatesResult = emailTemplatesOutcome.ok
1800
+ ? emailTemplatesOutcome.value
1801
+ : { templates: [] };
1544
1802
  // Fetch details for each entity
1545
1803
  const integrations = await Promise.all(integrationItems.map((i) => client.getIntegration(resolvedAppId, i.integrationId)));
1546
1804
  const prompts = await Promise.all(promptItems.map((p) => client.getPrompt(resolvedAppId, p.promptId)));
@@ -1565,30 +1823,71 @@ Directory Structure:
1565
1823
  }
1566
1824
  return workflowData;
1567
1825
  }));
1568
- // 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).
1569
1830
  const emailTemplateOverrides = emailTemplatesResult.templates.filter((t) => t.hasOverride);
1570
- const emailTemplates = await Promise.all(emailTemplateOverrides.map((t) => client.getEmailTemplate(resolvedAppId, t.emailType).catch(() => null))).then((results) => results.filter(Boolean));
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 };
1571
1842
  if (options.json) {
1572
1843
  json({ settings, integrations, prompts, workflows, emailTemplates });
1573
1844
  return;
1574
1845
  }
1575
1846
  // Fetch database config resources
1576
- const [databaseTypeConfigsResult, ruleSetsResult, groupTypeConfigsResult, collectionTypeConfigsResult, metadataCategoryConfigsResult,] = await Promise.all([
1577
- client.listDatabaseTypeConfigs(resolvedAppId).catch(() => []),
1578
- client.listRuleSets(resolvedAppId).catch(() => []),
1579
- client.listGroupTypeConfigs(resolvedAppId).catch(() => []),
1580
- client.listCollectionTypeConfigs(resolvedAppId).catch(() => []),
1581
- client.listMetadataCategoryConfigs(resolvedAppId).catch(() => []),
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)),
1582
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
+ : [];
1583
1873
  // Fetch operations + subscriptions for each database type. Issue #803:
1584
1874
  // subscriptions are pulled symmetrically with operations so a
1585
1875
  // pull → push cycle round-trips `[[subscriptions]]` blocks without a
1586
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;
1587
1882
  const databaseTypesWithOps = await Promise.all((Array.isArray(databaseTypeConfigsResult) ? databaseTypeConfigsResult : []).map(async (typeConfig) => {
1588
- const [ops, subs] = await Promise.all([
1589
- client.listDatabaseTypeOperations(resolvedAppId, typeConfig.databaseType).catch(() => []),
1590
- client.listDatabaseTypeSubscriptions(resolvedAppId, typeConfig.databaseType).catch(() => []),
1883
+ const [opsOutcome, subsOutcome] = await Promise.all([
1884
+ attemptFetch(() => client.listDatabaseTypeOperations(resolvedAppId, typeConfig.databaseType)),
1885
+ attemptFetch(() => client.listDatabaseTypeSubscriptions(resolvedAppId, typeConfig.databaseType)),
1591
1886
  ]);
1887
+ if (!opsOutcome.ok || !subsOutcome.ok)
1888
+ databaseTypeDetailsOk = false;
1889
+ const ops = opsOutcome.ok ? opsOutcome.value : [];
1890
+ const subs = subsOutcome.ok ? subsOutcome.value : [];
1592
1891
  return {
1593
1892
  typeConfig,
1594
1893
  operations: Array.isArray(ops) ? ops : [],
@@ -1639,12 +1938,50 @@ Directory Structure:
1639
1938
  ensureDir(join(configDir, "collection-type-configs"));
1640
1939
  ensureDir(join(configDir, "metadata-category-configs"));
1641
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 ?? {};
1642
1947
  // Write app settings
1643
1948
  if (settings) {
1644
1949
  const appTomlPath = join(configDir, "app.toml");
1645
1950
  writeFileSync(appTomlPath, serializeAppSettings(settings));
1646
1951
  info(" Wrote app.toml");
1647
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
+ }
1648
1985
  // Write integrations
1649
1986
  const integrationEntities = {};
1650
1987
  for (const integration of integrations) {
@@ -1675,15 +2012,13 @@ Directory Structure:
1675
2012
  };
1676
2013
  }
1677
2014
  info(` Pulled ${webhooks.length} webhook(s)`);
1678
- // Pull cron triggers
1679
- let cronTriggerItems = [];
1680
- try {
1681
- const cronResult = await client.listCronTriggers(resolvedAppId);
1682
- cronTriggerItems = cronResult.items || [];
1683
- }
1684
- catch {
1685
- // Cron triggers may not be available on older servers
1686
- }
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
+ : [];
1687
2022
  const cronTriggersDir = join(configDir, "cron-triggers");
1688
2023
  mkdirSync(cronTriggersDir, { recursive: true });
1689
2024
  const cronTriggerEntities = {};
@@ -1699,8 +2034,10 @@ Directory Structure:
1699
2034
  }
1700
2035
  info(` Pulled ${cronTriggerItems.length} cron trigger(s)`);
1701
2036
  // Pull blob buckets
1702
- const blobBucketsResult = await client.listBlobBuckets(resolvedAppId).catch(() => ({ items: [] }));
1703
- const blobBucketItems = blobBucketsResult.items || [];
2037
+ const blobBucketsOutcome = await attemptFetch(() => client.listBlobBuckets(resolvedAppId));
2038
+ const blobBucketItems = blobBucketsOutcome.ok
2039
+ ? blobBucketsOutcome.value.items || []
2040
+ : [];
1704
2041
  const blobBucketsDir = join(configDir, "blob-buckets");
1705
2042
  mkdirSync(blobBucketsDir, { recursive: true });
1706
2043
  const blobBucketEntities = {};
@@ -1753,7 +2090,7 @@ Directory Structure:
1753
2090
  // `transforms/<name>.rhai`, and record `{id, modifiedAt,
1754
2091
  // contentHash}` under `entities.scripts[name]` so a subsequent
1755
2092
  // `sync push` round-trips without false diffs.
1756
- 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);
1757
2094
  if (pulledScriptsCount > 0) {
1758
2095
  info(` Pulled ${pulledScriptsCount} transform(s)`);
1759
2096
  }
@@ -1805,19 +2142,10 @@ Directory Structure:
1805
2142
  };
1806
2143
  info(` Wrote email-templates/${filename}`);
1807
2144
  }
1808
- // Clean up local email template files for remotely-deleted overrides
1809
- const emailTemplatesDir = join(configDir, "email-templates");
1810
- if (existsSync(emailTemplatesDir)) {
1811
- const pulledTypes = new Set(emailTemplates.map((t) => t.emailType));
1812
- const localFiles = readdirSync(emailTemplatesDir).filter((f) => f.endsWith(".toml"));
1813
- for (const file of localFiles) {
1814
- const localType = basename(file, ".toml");
1815
- if (!pulledTypes.has(localType)) {
1816
- unlinkSync(join(emailTemplatesDir, file));
1817
- info(` Removed email-templates/${file} (override deleted on server)`);
1818
- }
1819
- }
1820
- }
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.
1821
2149
  // Pull test cases for prompts and workflows
1822
2150
  const testCaseEntities = {};
1823
2151
  let totalTestCases = 0;
@@ -1900,7 +2228,7 @@ Directory Structure:
1900
2228
  // Write rule sets
1901
2229
  const ruleSetEntities = {};
1902
2230
  for (const ruleSet of ruleSets) {
1903
- const fileKey = (ruleSet.name || ruleSet.ruleSetId).replace(/[/\\:*?"<>|]/g, "_");
2231
+ const fileKey = ruleSetFileKey(ruleSet);
1904
2232
  const filename = `${fileKey}.toml`;
1905
2233
  const filePath = join(configDir, "rule-sets", filename);
1906
2234
  writeFileSync(filePath, serializeRuleSet(ruleSet));
@@ -1956,6 +2284,182 @@ Directory Structure:
1956
2284
  };
1957
2285
  info(` Wrote metadata-category-configs/${filename}`);
1958
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
+ }
1959
2463
  // Save sync state
1960
2464
  const state = {
1961
2465
  appId: resolvedAppId,
@@ -1965,27 +2469,29 @@ Directory Structure:
1965
2469
  app: settings ? { modifiedAt: new Date().toISOString(), contentHash: computeFileHash(join(configDir, "app.toml")) } : undefined,
1966
2470
  integrations: integrationEntities,
1967
2471
  webhooks: webhookEntities,
1968
- cronTriggers: Object.keys(cronTriggerEntities).length > 0 ? cronTriggerEntities : undefined,
1969
- blobBuckets: Object.keys(blobBucketEntities).length > 0 ? blobBucketEntities : undefined,
2472
+ cronTriggers: Object.keys(finalCronTriggerEntities).length > 0 ? finalCronTriggerEntities : undefined,
2473
+ blobBuckets: Object.keys(finalBlobBucketEntities).length > 0 ? finalBlobBucketEntities : undefined,
1970
2474
  prompts: promptEntities,
1971
2475
  workflows: workflowEntities,
1972
- scripts: Object.keys(scriptEntities).length > 0 ? scriptEntities : undefined,
1973
- emailTemplates: Object.keys(emailTemplateEntities).length > 0 ? emailTemplateEntities : undefined,
2476
+ scripts: Object.keys(finalScriptEntities).length > 0 ? finalScriptEntities : undefined,
2477
+ emailTemplates: Object.keys(finalEmailTemplateEntities).length > 0 ? finalEmailTemplateEntities : undefined,
1974
2478
  testCases: Object.keys(testCaseEntities).length > 0 ? testCaseEntities : undefined,
1975
- databaseTypes: Object.keys(databaseTypeEntities).length > 0 ? databaseTypeEntities : undefined,
1976
- ruleSets: Object.keys(ruleSetEntities).length > 0 ? ruleSetEntities : undefined,
1977
- groupTypeConfigs: Object.keys(groupTypeConfigEntities).length > 0 ? groupTypeConfigEntities : undefined,
1978
- collectionTypeConfigs: Object.keys(collectionTypeConfigEntities).length > 0
1979
- ? collectionTypeConfigEntities
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
1980
2485
  : undefined,
1981
- metadataCategoryConfigs: Object.keys(metadataCategoryConfigEntities).length > 0
1982
- ? metadataCategoryConfigEntities
2486
+ metadataCategoryConfigs: Object.keys(finalMetadataCategoryConfigEntities).length > 0
2487
+ ? finalMetadataCategoryConfigEntities
1983
2488
  : undefined,
1984
2489
  },
1985
2490
  };
1986
2491
  saveSyncState(configDir, state);
1987
2492
  divider();
1988
2493
  success(`Pulled configuration to ${configDir}`);
2494
+ keyValue("Config Vars", Object.keys(varEntities).length);
1989
2495
  keyValue("Integrations", integrations.length);
1990
2496
  keyValue("Webhooks", webhooks.length);
1991
2497
  keyValue("Cron Triggers", cronTriggerItems.length);
@@ -2000,6 +2506,7 @@ Directory Structure:
2000
2506
  keyValue("Group Type Configs", groupTypeConfigs.length);
2001
2507
  keyValue("Collection Type Configs", collectionTypeConfigs.length);
2002
2508
  keyValue("Metadata Category Configs", metadataCategoryConfigs.length);
2509
+ keyValue("Pruned", prunedCount);
2003
2510
  }
2004
2511
  catch (err) {
2005
2512
  error(err.message);
@@ -2251,10 +2758,110 @@ Directory Structure:
2251
2758
  }
2252
2759
  }
2253
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
+ }
2254
2799
  if (preflightValidationErrors.length > 0) {
2255
2800
  throw new Error(`Aborting push: ${preflightValidationErrors.length} TOML validation error(s) — no changes were applied.\n` +
2256
2801
  preflightValidationErrors.join("\n"));
2257
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
+ }
2258
2865
  // Process app settings
2259
2866
  const appTomlPath = join(configDir, "app.toml");
2260
2867
  if (existsSync(appTomlPath)) {
@@ -2266,27 +2873,13 @@ Directory Structure:
2266
2873
  }
2267
2874
  else {
2268
2875
  const tomlData = parseTomlFile(appTomlPath);
2269
- const settings = {};
2270
- if (tomlData.app) {
2271
- Object.assign(settings, tomlData.app);
2272
- // Don't overwrite the target app's name during cross-app push
2273
- if (isCrossAppPush) {
2274
- delete settings.name;
2275
- }
2276
- }
2277
- if (tomlData.auth) {
2278
- const { settings: authSettings, warnings: authWarnings } = parseAppAuthSettings(tomlData.auth);
2279
- Object.assign(settings, authSettings);
2280
- for (const w of authWarnings) {
2281
- warn(` ${w}`);
2282
- }
2283
- }
2284
- if (tomlData.cors) {
2285
- settings.corsMode = tomlData.cors.mode;
2286
- settings.corsAllowedOrigins = tomlData.cors.allowedOrigins;
2287
- settings.corsAllowCredentials = tomlData.cors.allowCredentials;
2288
- settings.corsAllowedMethods = tomlData.cors.allowedMethods;
2289
- 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}`);
2290
2883
  }
2291
2884
  changes.push({ type: "app", action: "update", key: "settings" });
2292
2885
  if (!options.dryRun) {
@@ -2302,6 +2895,135 @@ Directory Structure:
2302
2895
  }
2303
2896
  }
2304
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
+ }
2305
3027
  // Process rule sets first (other entities may reference them by name)
2306
3028
  const ruleSetsDir = join(configDir, "rule-sets");
2307
3029
  if (existsSync(ruleSetsDir)) {
@@ -4982,12 +5704,45 @@ Directory Structure:
4982
5704
  localScripts.set(name, readFileSync(join(transformsDirPath, file), "utf-8"));
4983
5705
  }
4984
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
+ }
4985
5732
  // Compare
4986
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);
4987
5742
  // Integrations
4988
5743
  for (const key of localIntegrations) {
4989
5744
  if (!remoteIntegrations.has(key)) {
4990
- differences.push({ type: "integration", key, status: "local only" });
5745
+ differences.push({ type: "integration", key, status: localOnlyStatus("integrations", key) });
4991
5746
  }
4992
5747
  else {
4993
5748
  differences.push({ type: "integration", key, status: "exists" });
@@ -5001,7 +5756,7 @@ Directory Structure:
5001
5756
  // Webhooks
5002
5757
  for (const key of localWebhooks) {
5003
5758
  if (!remoteWebhooks.has(key)) {
5004
- differences.push({ type: "webhook", key, status: "local only" });
5759
+ differences.push({ type: "webhook", key, status: localOnlyStatus("webhooks", key) });
5005
5760
  }
5006
5761
  else {
5007
5762
  differences.push({ type: "webhook", key, status: "exists" });
@@ -5015,7 +5770,7 @@ Directory Structure:
5015
5770
  // Cron Triggers
5016
5771
  for (const key of localCronTriggers) {
5017
5772
  if (!remoteCronTriggers.has(key)) {
5018
- differences.push({ type: "cron-trigger", key, status: "local only" });
5773
+ differences.push({ type: "cron-trigger", key, status: localOnlyStatus("cronTriggers", key) });
5019
5774
  }
5020
5775
  else {
5021
5776
  differences.push({ type: "cron-trigger", key, status: "exists" });
@@ -5029,7 +5784,7 @@ Directory Structure:
5029
5784
  // Blob Buckets
5030
5785
  for (const key of localBlobBuckets) {
5031
5786
  if (!remoteBlobBuckets.has(key)) {
5032
- differences.push({ type: "blob-bucket", key, status: "local only" });
5787
+ differences.push({ type: "blob-bucket", key, status: localOnlyStatus("blobBuckets", key) });
5033
5788
  }
5034
5789
  else {
5035
5790
  differences.push({ type: "blob-bucket", key, status: "exists" });
@@ -5043,7 +5798,7 @@ Directory Structure:
5043
5798
  // Prompts
5044
5799
  for (const key of localPrompts) {
5045
5800
  if (!remotePrompts.has(key)) {
5046
- differences.push({ type: "prompt", key, status: "local only" });
5801
+ differences.push({ type: "prompt", key, status: localOnlyStatus("prompts", key) });
5047
5802
  }
5048
5803
  else {
5049
5804
  differences.push({ type: "prompt", key, status: "exists" });
@@ -5075,7 +5830,7 @@ Directory Structure:
5075
5830
  const remoteWorkflowIds = new Map(workflowItems.map((w) => [w.workflowKey, w.workflowId]));
5076
5831
  for (const key of localWorkflows) {
5077
5832
  if (!remoteWorkflows.has(key)) {
5078
- differences.push({ type: "workflow", key, status: "local only" });
5833
+ differences.push({ type: "workflow", key, status: localOnlyStatus("workflows", key) });
5079
5834
  continue;
5080
5835
  }
5081
5836
  // Present on both sides → compare content.
@@ -5126,7 +5881,7 @@ Directory Structure:
5126
5881
  // Email Templates
5127
5882
  for (const key of localEmailTemplates) {
5128
5883
  if (!remoteEmailTemplates.has(key)) {
5129
- differences.push({ type: "email-template", key, status: "local only" });
5884
+ differences.push({ type: "email-template", key, status: localOnlyStatus("emailTemplates", key) });
5130
5885
  }
5131
5886
  else {
5132
5887
  differences.push({ type: "email-template", key, status: "exists" });
@@ -5147,7 +5902,7 @@ Directory Structure:
5147
5902
  for (const [name, localBody] of localScripts) {
5148
5903
  const remote = remoteScripts.get(name);
5149
5904
  if (!remote) {
5150
- differences.push({ type: "transform", key: name, status: "local only" });
5905
+ differences.push({ type: "transform", key: name, status: localOnlyStatus("scripts", name) });
5151
5906
  continue;
5152
5907
  }
5153
5908
  let status = "exists";
@@ -5186,6 +5941,10 @@ Directory Structure:
5186
5941
  differences.push({ type: "transform", key: name, status: "remote only" });
5187
5942
  }
5188
5943
  }
5944
+ // Config vars — issue #1423 (see `diffVars`).
5945
+ for (const row of diffVars(localVars, remoteVars)) {
5946
+ differences.push(row);
5947
+ }
5189
5948
  // Compare test cases for synced prompts and workflows
5190
5949
  const testCaseDiffs = [];
5191
5950
  // Helper to compare test cases for a block
@@ -5210,7 +5969,7 @@ Directory Structure:
5210
5969
  }
5211
5970
  for (const slug of localTestSlugs) {
5212
5971
  if (!remoteTestNames.has(slug)) {
5213
- testCaseDiffs.push({ blockType, blockKey, slug, status: "local only" });
5972
+ testCaseDiffs.push({ blockType, blockKey, slug, status: LOCAL_ONLY_NEW });
5214
5973
  }
5215
5974
  else {
5216
5975
  testCaseDiffs.push({ blockType, blockKey, slug, status: "exists" });
@@ -5234,17 +5993,29 @@ Directory Structure:
5234
5993
  }
5235
5994
  }
5236
5995
  divider();
5237
- const localOnly = differences.filter((d) => d.status === "local only");
5996
+ const localOnly = differences.filter((d) => d.status === LOCAL_ONLY_NEW);
5997
+ const localOnlyAbsent = differences.filter((d) => d.status === LOCAL_ONLY_ABSENT);
5238
5998
  const remoteOnly = differences.filter((d) => d.status === "remote only");
5239
5999
  const modified = differences.filter((d) => d.status === "modified");
5240
6000
  const existing = differences.filter((d) => d.status === "exists");
5241
6001
  if (localOnly.length > 0) {
5242
- info("Local only (will be created on push):");
6002
+ info("Local only — new (will be created on push):");
5243
6003
  for (const d of localOnly) {
5244
6004
  console.log(` ${chalk.green("+")} ${d.type}: ${d.key}`);
5245
6005
  }
5246
6006
  console.log();
5247
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
+ }
5248
6019
  if (remoteOnly.length > 0) {
5249
6020
  warn("Remote only (not in local config):");
5250
6021
  for (const d of remoteOnly) {
@@ -5271,7 +6042,7 @@ Directory Structure:
5271
6042
  }
5272
6043
  }
5273
6044
  // Show test case differences
5274
- const tcLocalOnly = testCaseDiffs.filter((d) => d.status === "local only");
6045
+ const tcLocalOnly = testCaseDiffs.filter((d) => d.status === LOCAL_ONLY_NEW);
5275
6046
  const tcRemoteOnly = testCaseDiffs.filter((d) => d.status === "remote only");
5276
6047
  const tcSynced = testCaseDiffs.filter((d) => d.status === "exists");
5277
6048
  if (tcLocalOnly.length > 0 || tcRemoteOnly.length > 0) {
@@ -5288,7 +6059,8 @@ Directory Structure:
5288
6059
  }
5289
6060
  }
5290
6061
  divider();
5291
- keyValue("Local only", localOnly.length);
6062
+ keyValue("Local only (new)", localOnly.length);
6063
+ keyValue("Local only (absent from export)", localOnlyAbsent.length);
5292
6064
  keyValue("Remote only", remoteOnly.length);
5293
6065
  keyValue("Modified", modified.length);
5294
6066
  keyValue("Synced", existing.length);
@@ -5615,24 +6387,4 @@ Directory Structure:
5615
6387
  info("Run 'primitive sync diff' to inspect the restored state versus the server.");
5616
6388
  });
5617
6389
  }
5618
- /**
5619
- * Best-effort check for uncommitted git changes under `dir`. Used only to warn
5620
- * before a revert overwrites local edits — never fatal. Returns false if git
5621
- * isn't available, the dir isn't in a repo, or anything goes wrong.
5622
- */
5623
- async function hasUncommittedChanges(dir) {
5624
- if (!existsSync(dir))
5625
- return false;
5626
- try {
5627
- const { execSync } = await import("child_process");
5628
- const out = execSync(`git status --porcelain -- "${dir}"`, {
5629
- stdio: ["ignore", "pipe", "ignore"],
5630
- encoding: "utf-8",
5631
- });
5632
- return out.trim().length > 0;
5633
- }
5634
- catch {
5635
- return false;
5636
- }
5637
- }
5638
6390
  //# sourceMappingURL=sync.js.map