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

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.
@@ -341,6 +341,312 @@ function serializeAppSettings(settings) {
341
341
  }
342
342
  return stringifyConfigToml(data);
343
343
  }
344
+ // --- Config vars (issue #1423) ---------------------------------------------
345
+ // Per-environment non-secret scalars, round-tripped as a single flat
346
+ // `vars.toml` at the sync-dir root (like app.toml). They bind as
347
+ // `{{ vars.KEY }}` in workflow/integration config and `vars.*` in CEL rules.
348
+ /** Key format shared with the vars service (`^[A-Z][A-Z0-9_]{0,63}$`). */
349
+ const VARS_KEY_RE = /^[A-Z][A-Z0-9_]{0,63}$/;
350
+ /** SHA-256 of a var's value — the per-key content hash tracked in sync state. */
351
+ function hashVarValue(value) {
352
+ return createHash("sha256").update(value).digest("hex");
353
+ }
354
+ /**
355
+ * Serialize the app config vars to a flat TOML table (keys sorted for a stable,
356
+ * diff-friendly file). Values are non-secret plaintext; a header comment says
357
+ * so loudly. An empty var set yields a comment-only file.
358
+ */
359
+ function serializeVars(vars) {
360
+ const data = {};
361
+ for (const v of [...vars].sort((a, b) => a.key.localeCompare(b.key))) {
362
+ data[v.key] = String(v.value ?? "");
363
+ }
364
+ const header = "# Per-environment non-secret config vars (issue #1423).\n" +
365
+ "# Bind as {{ vars.KEY }} in workflow/integration config and vars.* in CEL\n" +
366
+ "# rules. Values are checked into the repo and NOT secret — never put a\n" +
367
+ "# credential here; use `primitive secrets` for that.\n\n";
368
+ return header + stringifyConfigToml(data);
369
+ }
370
+ /**
371
+ * Decide whether `sync pull` should (over)write `vars.toml`, given the outcome
372
+ * of fetching the app's config vars.
373
+ *
374
+ * The distinction that matters (issue #1423 review): a SUCCESSFUL fetch that
375
+ * returns zero vars is safe to write — the comment-only file is the intended
376
+ * empty state. A FAILED fetch (transient 401/500/network) must NOT write,
377
+ * because `serializeVars([])` would clobber a good local `vars.toml` with a
378
+ * comment-only file while the pull otherwise reports success. On failure we
379
+ * keep the prior manifest entries so the untouched file and the sync state stay
380
+ * consistent.
381
+ *
382
+ * Pure and side-effect-free (the caller owns the actual `writeFileSync`) so the
383
+ * clobber guard is unit-testable without a live server.
384
+ */
385
+ export function planVarsPull(outcome, priorVars) {
386
+ const varEntities = {};
387
+ if (!outcome.ok) {
388
+ // Preserve the prior manifest entries for the (untouched) local file.
389
+ if (priorVars)
390
+ Object.assign(varEntities, priorVars);
391
+ return { write: false, content: null, varEntities };
392
+ }
393
+ for (const v of outcome.vars) {
394
+ varEntities[v.key] = {
395
+ // The vars API returns `updatedAt` (see toVarResponse); record it as the
396
+ // baseline server timestamp so `sync push` can detect a concurrent remote
397
+ // edit. Fall back to `modifiedAt`/now only for older/partial responses.
398
+ modifiedAt: v.updatedAt || v.modifiedAt || new Date().toISOString(),
399
+ contentHash: hashVarValue(String(v.value ?? "")),
400
+ };
401
+ }
402
+ return { write: true, content: serializeVars(outcome.vars), varEntities };
403
+ }
404
+ /**
405
+ * Maximum var value size, in bytes — mirrors the server's `MAX_VALUE_BYTES`
406
+ * (app-secrets-service.ts). Kept in sync manually because the CLI can't import
407
+ * the Worker service module.
408
+ */
409
+ const MAX_VAR_VALUE_BYTES = 2048;
410
+ /**
411
+ * Maximum number of vars per app — mirrors the server's `MAX_VARS_PER_APP`
412
+ * (app-config-vars-service.ts). Kept in sync manually because the CLI can't
413
+ * import the Worker service module.
414
+ */
415
+ const MAX_VARS_PER_APP = 100;
416
+ /**
417
+ * Validate a parsed `vars.toml` table against the same key/value constraints
418
+ * the server enforces (key format, string type, non-empty, size cap) plus the
419
+ * aggregate per-app var-count cap. Returns a list of human-readable errors
420
+ * (empty when valid).
421
+ *
422
+ * Run in `sync push`'s up-front preflight pass (issue #1423 review) so an
423
+ * invalid entry — or a file that would exceed the server's `MAX_VARS_PER_APP`
424
+ * cap — aborts BEFORE any mutation is applied. Validating only individual
425
+ * entries let a 101-entry file create/update many vars before the server
426
+ * rejected a later create at the cap, leaving a partial push (issue #1423
427
+ * review pass 3). Pure and side-effect-free so it's unit-testable without a
428
+ * live server.
429
+ */
430
+ export function validateVarsFile(parsedVars) {
431
+ const errors = [];
432
+ const keyCount = Object.keys(parsedVars).length;
433
+ if (keyCount > MAX_VARS_PER_APP) {
434
+ errors.push(`vars.toml declares ${keyCount} vars, exceeding the maximum of ${MAX_VARS_PER_APP} vars per app. Remove ${keyCount - MAX_VARS_PER_APP} entr${keyCount - MAX_VARS_PER_APP === 1 ? "y" : "ies"}.`);
435
+ }
436
+ for (const [key, rawValue] of Object.entries(parsedVars)) {
437
+ if (!VARS_KEY_RE.test(key)) {
438
+ errors.push(`Invalid var key "${key}" in vars.toml: must match ^[A-Z][A-Z0-9_]{0,63}$ (uppercase letters, digits, underscores; start with a letter; max 64 chars)`);
439
+ continue;
440
+ }
441
+ if (typeof rawValue !== "string") {
442
+ errors.push(`Var "${key}" in vars.toml must be a string value (got ${typeof rawValue}) — quote the value.`);
443
+ continue;
444
+ }
445
+ const byteLength = new TextEncoder().encode(rawValue).length;
446
+ if (byteLength === 0) {
447
+ errors.push(`Var "${key}" in vars.toml must not be empty.`);
448
+ }
449
+ else if (byteLength > MAX_VAR_VALUE_BYTES) {
450
+ errors.push(`Var "${key}" in vars.toml exceeds the maximum value size of ${MAX_VAR_VALUE_BYTES} bytes (got ${byteLength}).`);
451
+ }
452
+ }
453
+ return errors;
454
+ }
455
+ /**
456
+ * Plan the config-var writes a `sync push` should make, detecting concurrent
457
+ * remote edits before overwriting them (issue #1423 review).
458
+ *
459
+ * This is the client-side half of a two-layer concurrency guard. The vars
460
+ * PUT/DELETE endpoints now honor an `expectedModifiedAt` precondition (issue
461
+ * #1423 review r-2 P1) and raise a `ConflictError` when the server row changed
462
+ * since the caller's snapshot — the atomic, server-side check every other
463
+ * synced entity relies on. This function is the fast fail-before-mutate layer:
464
+ * it compares the recorded baseline against the current remote snapshot and, for
465
+ * a var whose remote value has drifted from what we last synced, reports a
466
+ * conflict (fed into the same `conflicts[]` accumulator the other entities use)
467
+ * before any write is attempted. A var tracked at the baseline but absent from
468
+ * the remote snapshot (deleted remotely while edited locally) is remote drift
469
+ * too, and also reported as a conflict rather than silently recreated.
470
+ * Conversely, a var removed locally that is already absent remotely is NOT
471
+ * planned for deletion — its desired (absent) state is already met, so a DELETE
472
+ * would 404 and fail the push. The same drift check guards deletions: a var
473
+ * removed locally whose remote value no longer matches the baseline was edited
474
+ * remotely since the last sync, and deleting it would silently discard that
475
+ * edit — reported as a conflict instead. `force` skips the conflict guard,
476
+ * matching `sync push --force` for every other entity.
477
+ *
478
+ * Pure and side-effect-free (the caller owns the API calls and sync-state
479
+ * updates) so the guard is unit-testable without a live server. Assumes
480
+ * `parsedVars` has already passed `validateVarsFile` (values are strings). The
481
+ * caller normally fails the push closed when the remote snapshot can't be
482
+ * fetched, but under `--force` it falls back to an empty snapshot and sets
483
+ * `snapshotUnavailable` — which keeps removed-key deletions in the plan (they'd
484
+ * otherwise be mistaken for already-deleted and dropped) and leans on the apply
485
+ * loop's idempotent 404 handling (issue #1423 review r-2 P2).
486
+ */
487
+ export function planVarsPush(parsedVars, baseline, remoteVars, options = {}) {
488
+ const remoteByKey = new Map(remoteVars.map((r) => [r.key, r]));
489
+ const upserts = [];
490
+ const conflicts = [];
491
+ const deletions = [];
492
+ let skippedCount = 0;
493
+ const localKeys = new Set();
494
+ for (const [key, value] of Object.entries(parsedVars)) {
495
+ localKeys.add(key);
496
+ const valueHash = hashVarValue(value);
497
+ const existing = baseline?.[key];
498
+ // Unchanged since last sync — skip (unless forced), mirroring the
499
+ // content-hash skip the other file-backed entities use.
500
+ if (!options.force && existing && existing.contentHash === valueHash) {
501
+ skippedCount++;
502
+ continue;
503
+ }
504
+ const action = existing ? "update" : "create";
505
+ // Concurrent-edit guard: if the remote value has drifted from the baseline
506
+ // we last synced (for an update) — or a var with this key was created
507
+ // remotely with a different value (for a create) — pushing would silently
508
+ // clobber that remote change. Report a conflict instead. `--force`
509
+ // overrides, exactly like the other entities.
510
+ if (!options.force) {
511
+ const remote = remoteByKey.get(key);
512
+ if (remote && typeof remote.value === "string") {
513
+ const remoteHash = hashVarValue(remote.value);
514
+ const remoteDrifted = existing?.contentHash
515
+ ? remoteHash !== existing.contentHash
516
+ : remoteHash !== valueHash;
517
+ if (remoteDrifted) {
518
+ conflicts.push({
519
+ key,
520
+ serverModifiedAt: remote.updatedAt || "unknown",
521
+ localModifiedAt: existing?.modifiedAt || "unknown",
522
+ });
523
+ continue;
524
+ }
525
+ }
526
+ else if (existing) {
527
+ // Tracked at the baseline but absent from the remote snapshot: the var
528
+ // was deleted remotely since our last sync while we edited it locally
529
+ // (we only reach here past the unchanged-skip above, so the local value
530
+ // differs from the baseline). Upserting would recreate the
531
+ // remotely-deleted var — that missing remote value is remote drift too,
532
+ // so report a conflict instead (issue #1423 review pass 3). `--force`
533
+ // overrides.
534
+ conflicts.push({
535
+ key,
536
+ serverModifiedAt: "deleted",
537
+ localModifiedAt: existing.modifiedAt || "unknown",
538
+ });
539
+ continue;
540
+ }
541
+ }
542
+ upserts.push({ action, key, value, valueHash });
543
+ }
544
+ // Prune vars tracked in the baseline but removed from the local file
545
+ // (hard-delete; nested-entity precedent). Skip keys already absent from the
546
+ // remote snapshot: a var independently deleted remotely and also removed
547
+ // locally is already in its desired (absent) state, and a server DELETE would
548
+ // 404 and fail the whole push (issue #1423 review pass 3). `--force` does not
549
+ // resurrect these deletes — the desired state is met either way.
550
+ for (const [key, entry] of Object.entries(baseline ?? {})) {
551
+ if (localKeys.has(key))
552
+ continue;
553
+ const remote = remoteByKey.get(key);
554
+ if (!remote) {
555
+ // Normally an absent remote entry means the var is already gone, so its
556
+ // desired (deleted) state is met and a DELETE would 404 — skip it. But
557
+ // when the remote snapshot could NOT be fetched (the `--force` fallback
558
+ // after a failed `listAppConfigVars`, which passes an empty snapshot),
559
+ // "absent from the snapshot" means "unknown", not "gone". Suppressing the
560
+ // delete would silently keep a var the user removed from `vars.toml` while
561
+ // the push still reports success (issue #1423 review r-2 P2). Enqueue the
562
+ // removal and rely on the apply loop's idempotent 404 handling to absorb a
563
+ // key that really was already absent.
564
+ if (options.snapshotUnavailable)
565
+ deletions.push(key);
566
+ continue;
567
+ }
568
+ // Concurrent-edit guard, mirroring the upsert path (issue #1423 review
569
+ // pass 4): deleting a var whose remote value drifted from the last-synced
570
+ // baseline would silently discard that remote edit. When drift cannot be
571
+ // verified (no baseline hash, or no remote value in the snapshot), fail
572
+ // closed and report the conflict. `--force` deletes unconditionally.
573
+ if (!options.force) {
574
+ const remoteDrifted = typeof remote.value !== "string" ||
575
+ !entry.contentHash ||
576
+ hashVarValue(remote.value) !== entry.contentHash;
577
+ if (remoteDrifted) {
578
+ conflicts.push({
579
+ key,
580
+ serverModifiedAt: remote.updatedAt || "unknown",
581
+ localModifiedAt: entry.modifiedAt || "unknown",
582
+ });
583
+ continue;
584
+ }
585
+ }
586
+ deletions.push(key);
587
+ }
588
+ return { upserts, deletions, conflicts, skippedCount };
589
+ }
590
+ /**
591
+ * Compute the number of config vars the server would hold after a push plan
592
+ * is applied (issue #1423 review pass 5). Validating only the local file's
593
+ * entry count misses remote-only vars: 95 local entries plus 10 vars that
594
+ * exist only on the server passes a local-count check, and the creates then
595
+ * exceed the server's cap mid-push, after earlier writes were already
596
+ * applied. Counted from the fetched remote snapshot: planned deletions remove
597
+ * keys that exist remotely (the plan already omits deletes for absent keys),
598
+ * and only upserts whose key is NOT already on the server add to the total.
599
+ * Conflicted keys are not mutated, so they contribute nothing beyond their
600
+ * current remote presence. Pure and side-effect-free so it's unit-testable
601
+ * without a live server.
602
+ */
603
+ export function countVarsAfterPush(remoteVars, plan) {
604
+ const remoteKeys = new Set(remoteVars.map((r) => r.key));
605
+ let count = remoteKeys.size;
606
+ for (const key of plan.deletions) {
607
+ if (remoteKeys.has(key))
608
+ count--;
609
+ }
610
+ for (const upsert of plan.upserts) {
611
+ if (!remoteKeys.has(upsert.key))
612
+ count++;
613
+ }
614
+ return count;
615
+ }
616
+ /**
617
+ * Compute the `sync diff` rows for config vars (issue #1423 review). Before
618
+ * this, `sync diff` ignored vars entirely, so an add/remove/value-drift between
619
+ * the local `vars.toml` and the server read as no difference. Value-aware: a
620
+ * var present on both sides whose value differs is reported as `modified`
621
+ * (framed like the other content-aware entities — `sync pull` would rewrite the
622
+ * local value). Pure and side-effect-free so it's unit-testable without a live
623
+ * server.
624
+ */
625
+ export function diffVars(localVars, remoteVars) {
626
+ const rows = [];
627
+ for (const [key, localValue] of localVars) {
628
+ if (!remoteVars.has(key)) {
629
+ rows.push({ type: "var", key, status: "local only" });
630
+ }
631
+ else if (remoteVars.get(key) !== localValue) {
632
+ rows.push({
633
+ type: "var",
634
+ key,
635
+ status: "modified",
636
+ hint: "run `sync pull` to rewrite vars.toml to match the server value",
637
+ });
638
+ }
639
+ else {
640
+ rows.push({ type: "var", key, status: "exists" });
641
+ }
642
+ }
643
+ for (const key of remoteVars.keys()) {
644
+ if (!localVars.has(key)) {
645
+ rows.push({ type: "var", key, status: "remote only" });
646
+ }
647
+ }
648
+ return rows;
649
+ }
344
650
  function serializeIntegration(integration) {
345
651
  const data = {
346
652
  integration: {
@@ -1645,6 +1951,38 @@ Directory Structure:
1645
1951
  writeFileSync(appTomlPath, serializeAppSettings(settings));
1646
1952
  info(" Wrote app.toml");
1647
1953
  }
1954
+ // Write config vars (issue #1423). A single flat vars.toml at the
1955
+ // sync-dir root (like app.toml), holding the per-environment non-secret
1956
+ // scalars. Written (comment-only when empty) so the managed file exists
1957
+ // and a later push knows the vars surface is under sync control.
1958
+ //
1959
+ // Distinguish "app genuinely has zero vars" (a successful empty
1960
+ // response — safe to write the comment-only file) from "fetch failed"
1961
+ // (a transient 401/500/network error). Previously a `.catch(() => [])`
1962
+ // conflated the two, so a transient error silently clobbered a good
1963
+ // local vars.toml with a comment-only file while reporting success. On a
1964
+ // fetch error we now leave the existing vars.toml untouched and preserve
1965
+ // the prior manifest entry so state stays consistent with the file.
1966
+ const varsTomlPathPull = join(configDir, "vars.toml");
1967
+ let varsOutcome;
1968
+ try {
1969
+ varsOutcome = { ok: true, vars: await client.listAppConfigVars(resolvedAppId) };
1970
+ }
1971
+ catch (varErr) {
1972
+ varsOutcome = { ok: false, error: varErr };
1973
+ }
1974
+ const varsPlan = planVarsPull(varsOutcome, varsOutcome.ok ? undefined : loadSyncState(configDir)?.entities?.vars);
1975
+ const varEntities = varsPlan.varEntities;
1976
+ if (varsPlan.write && varsPlan.content !== null) {
1977
+ writeFileSync(varsTomlPathPull, varsPlan.content);
1978
+ const count = varsOutcome.vars.length;
1979
+ info(` Wrote vars.toml (${count} var${count === 1 ? "" : "s"})`);
1980
+ }
1981
+ else {
1982
+ const err = varsOutcome.error;
1983
+ error(` Skipped vars.toml — failed to fetch config vars: ${err?.message ?? err}. ` +
1984
+ "Left the existing vars.toml untouched.");
1985
+ }
1648
1986
  // Write integrations
1649
1987
  const integrationEntities = {};
1650
1988
  for (const integration of integrations) {
@@ -1974,6 +2312,7 @@ Directory Structure:
1974
2312
  testCases: Object.keys(testCaseEntities).length > 0 ? testCaseEntities : undefined,
1975
2313
  databaseTypes: Object.keys(databaseTypeEntities).length > 0 ? databaseTypeEntities : undefined,
1976
2314
  ruleSets: Object.keys(ruleSetEntities).length > 0 ? ruleSetEntities : undefined,
2315
+ vars: Object.keys(varEntities).length > 0 ? varEntities : undefined,
1977
2316
  groupTypeConfigs: Object.keys(groupTypeConfigEntities).length > 0 ? groupTypeConfigEntities : undefined,
1978
2317
  collectionTypeConfigs: Object.keys(collectionTypeConfigEntities).length > 0
1979
2318
  ? collectionTypeConfigEntities
@@ -1986,6 +2325,7 @@ Directory Structure:
1986
2325
  saveSyncState(configDir, state);
1987
2326
  divider();
1988
2327
  success(`Pulled configuration to ${configDir}`);
2328
+ keyValue("Config Vars", Object.keys(varEntities).length);
1989
2329
  keyValue("Integrations", integrations.length);
1990
2330
  keyValue("Webhooks", webhooks.length);
1991
2331
  keyValue("Cron Triggers", cronTriggerItems.length);
@@ -2251,10 +2591,91 @@ Directory Structure:
2251
2591
  }
2252
2592
  }
2253
2593
  }
2594
+ // Validate config vars up-front (issue #1423 review). Parse vars.toml
2595
+ // and check the same key/value constraints the server enforces here, in
2596
+ // the preflight pass, so an invalid entry aborts BEFORE any mutation
2597
+ // (app settings or a var upsert) is applied. Previously validation ran
2598
+ // mid-push, after app settings could already have been updated.
2599
+ const preflightVarsTomlPath = join(configDir, "vars.toml");
2600
+ let preflightParsedVars = null;
2601
+ if (existsSync(preflightVarsTomlPath)) {
2602
+ try {
2603
+ const parsed = parseTomlFile(preflightVarsTomlPath);
2604
+ for (const e of validateVarsFile(parsed)) {
2605
+ preflightValidationErrors.push(` ${e}`);
2606
+ }
2607
+ preflightParsedVars = parsed;
2608
+ }
2609
+ catch (err) {
2610
+ preflightValidationErrors.push(` vars.toml: ${err?.message || String(err)}`);
2611
+ }
2612
+ }
2254
2613
  if (preflightValidationErrors.length > 0) {
2255
2614
  throw new Error(`Aborting push: ${preflightValidationErrors.length} TOML validation error(s) — no changes were applied.\n` +
2256
2615
  preflightValidationErrors.join("\n"));
2257
2616
  }
2617
+ // Fetch the current remote config vars up-front — before ANY mutation
2618
+ // (issue #1423 review pass 3). `planVarsPush` needs this snapshot both
2619
+ // to detect concurrent remote edits and to decide which already-absent
2620
+ // deletes to skip. A failed fetch must FAIL CLOSED: proceeding with an
2621
+ // empty snapshot would silently disable the concurrency guard and let a
2622
+ // changed local value clobber a remote edit. Abort unless `--force`.
2623
+ // Fetching here (not mid-push) keeps the abort fail-before-mutate.
2624
+ let remoteVarsSnapshot = [];
2625
+ // Whether the snapshot above is authoritative. A failed fetch under
2626
+ // `--force` leaves it empty but UNKNOWN (not "the server has no vars") —
2627
+ // `planVarsPush` needs this so it doesn't mistake "absent from the empty
2628
+ // snapshot" for "already deleted remotely" and silently drop deletions
2629
+ // the user asked for (issue #1423 review r-2 P2).
2630
+ let varSnapshotUnavailable = false;
2631
+ if (existsSync(preflightVarsTomlPath)) {
2632
+ try {
2633
+ remoteVarsSnapshot = await client.listAppConfigVars(resolvedAppId);
2634
+ }
2635
+ catch (err) {
2636
+ if (!options.force) {
2637
+ throw new Error(`Aborting push: could not fetch remote config vars to check for ` +
2638
+ `concurrent edits (${err?.message || String(err)}) — no changes ` +
2639
+ `were applied. Re-run with --force to push without the ` +
2640
+ `concurrency guard.`);
2641
+ }
2642
+ // --force: proceed without a snapshot; the guard is intentionally
2643
+ // bypassed and every local var is upserted as authoritative. The
2644
+ // snapshot is unknown, so deletions of removed keys are still
2645
+ // enqueued (idempotent 404 handling absorbs already-absent keys).
2646
+ remoteVarsSnapshot = [];
2647
+ varSnapshotUnavailable = true;
2648
+ }
2649
+ }
2650
+ // Plan the var writes up-front and validate the FINAL server var
2651
+ // count against the cap BEFORE any mutation (issue #1423 review pass
2652
+ // 5). `validateVarsFile` above caps the local file's entry count, but
2653
+ // remote-only vars (on the server, not in vars.toml) still count
2654
+ // toward the server's limit: 95 local entries plus 10 remote-only
2655
+ // vars passes the local check, then a later create is rejected at the
2656
+ // cap after earlier writes were already applied. Computed from the
2657
+ // remote snapshot plus planned creates minus planned deletions. The
2658
+ // check applies even with `--force`: the cap is a server hard limit,
2659
+ // not a conflict guard, so a push that would exceed it cannot succeed
2660
+ // and forcing it would only leave a partial push. (Under `--force`
2661
+ // with a failed snapshot fetch the remote count is unknown; the check
2662
+ // then degrades to the local file count, like the concurrency guard.)
2663
+ let varPlan = null;
2664
+ if (preflightParsedVars) {
2665
+ varPlan = planVarsPush(preflightParsedVars, syncState?.entities?.vars, remoteVarsSnapshot, { force: options.force, snapshotUnavailable: varSnapshotUnavailable });
2666
+ const finalVarCount = countVarsAfterPush(remoteVarsSnapshot, varPlan);
2667
+ if (finalVarCount > MAX_VARS_PER_APP) {
2668
+ const localKeys = new Set(Object.keys(preflightParsedVars));
2669
+ const plannedDeletes = new Set(varPlan.deletions);
2670
+ const remoteOnlyCount = remoteVarsSnapshot.filter((r) => !localKeys.has(r.key) && !plannedDeletes.has(r.key)).length;
2671
+ throw new Error(`Aborting push: it would leave ${finalVarCount} config vars on the server, ` +
2672
+ `exceeding the maximum of ${MAX_VARS_PER_APP} vars per app ` +
2673
+ `(server has ${remoteVarsSnapshot.length} vars, ${remoteOnlyCount} of ` +
2674
+ `which are not in vars.toml) — no changes were applied. Run ` +
2675
+ `\`sync pull\` to bring the remote-only vars into vars.toml, or ` +
2676
+ `delete unneeded vars, then push again.`);
2677
+ }
2678
+ }
2258
2679
  // Process app settings
2259
2680
  const appTomlPath = join(configDir, "app.toml");
2260
2681
  if (existsSync(appTomlPath)) {
@@ -2302,6 +2723,135 @@ Directory Structure:
2302
2723
  }
2303
2724
  }
2304
2725
  }
2726
+ // Process config vars (issue #1423). Read the flat vars.toml, upsert
2727
+ // each key (PUT by key is idempotent — one call covers create+update),
2728
+ // and hard-delete keys removed from the file (nested-entity precedent:
2729
+ // database operations/subscriptions). Pruning runs ONLY when the file
2730
+ // is present, so an absent vars.toml never mass-deletes server vars.
2731
+ //
2732
+ // Key/value validation now runs up-front in the preflight pass above,
2733
+ // so by the time we get here the file is known-valid (values are
2734
+ // strings). The remote-vars snapshot was also fetched up-front (fail
2735
+ // closed) so `planVarsPush` can detect a concurrent remote edit and
2736
+ // report it as a conflict instead of silently overwriting it (review
2737
+ // r-2 P1), and the plan itself was computed up-front so the final
2738
+ // server var count could be validated against the cap before any
2739
+ // mutation (review pass 5).
2740
+ if (varPlan) {
2741
+ skippedCount += varPlan.skippedCount;
2742
+ // Feed var conflicts into the shared accumulator — same convention as
2743
+ // every other entity, so the end-of-run report renders them as
2744
+ // `CONFLICT var: KEY` and the push exits non-zero.
2745
+ for (const c of varPlan.conflicts) {
2746
+ conflicts.push({
2747
+ type: "var",
2748
+ key: c.key,
2749
+ serverModifiedAt: c.serverModifiedAt,
2750
+ localModifiedAt: c.localModifiedAt,
2751
+ });
2752
+ }
2753
+ // Delete removed keys BEFORE upserting new ones (issue #1423 review
2754
+ // pass 3). At the server's 100-var cap, a replacement (remove one key,
2755
+ // add another) is a valid final state, but upserting the new key first
2756
+ // would transiently exceed the cap and the create would be rejected.
2757
+ // Freeing the removed slots first lets the replacement synchronize.
2758
+ // Each removal prints an "Unsetting var X" line so the destructive
2759
+ // action is visible.
2760
+ for (const key of varPlan.deletions) {
2761
+ changes.push({ type: "var", action: "delete", key });
2762
+ if (!options.dryRun) {
2763
+ // Server-side concurrency guard (issue #1423 review r-2 P1): send
2764
+ // the `updatedAt` we last synced so the server rejects the delete
2765
+ // (409 CONFLICT) if the var was edited remotely since — closing the
2766
+ // race between the client snapshot and this DELETE that the
2767
+ // client-side `planVarsPush` guard alone can't. `--force` omits it
2768
+ // and deletes unconditionally.
2769
+ const expectedModifiedAt = options.force
2770
+ ? undefined
2771
+ : syncState?.entities?.vars?.[key]?.modifiedAt;
2772
+ try {
2773
+ await client.deleteAppConfigVar(resolvedAppId, key, expectedModifiedAt);
2774
+ info(` Unsetting var ${key}`);
2775
+ if (syncState?.entities?.vars)
2776
+ delete syncState.entities.vars[key];
2777
+ }
2778
+ catch (err) {
2779
+ // A remote edit landed since our snapshot — report a conflict
2780
+ // instead of discarding it (feeds the shared accumulator →
2781
+ // `CONFLICT var: KEY`, non-zero exit).
2782
+ if (err instanceof ConflictError) {
2783
+ conflicts.push({
2784
+ type: "var",
2785
+ key,
2786
+ serverModifiedAt: err.serverModifiedAt,
2787
+ localModifiedAt: expectedModifiedAt || "unknown",
2788
+ });
2789
+ }
2790
+ else if (err instanceof ApiError && err.statusCode === 404) {
2791
+ // A 404 means the var is already absent — the desired
2792
+ // (deleted) state is met, so treat it as an idempotent success
2793
+ // rather than failing the whole push (issue #1423 review pass
2794
+ // 3). The plan omits deletes for keys absent from a fetched
2795
+ // snapshot; this also absorbs the forced no-snapshot fallback
2796
+ // (r-2 P2), where removed keys are enqueued unconditionally.
2797
+ info(` Var ${key} already absent (nothing to unset)`);
2798
+ if (syncState?.entities?.vars)
2799
+ delete syncState.entities.vars[key];
2800
+ }
2801
+ else {
2802
+ throw wrapEntityError(err, "delete", "var", key);
2803
+ }
2804
+ }
2805
+ }
2806
+ }
2807
+ for (const item of varPlan.upserts) {
2808
+ changes.push({ type: "var", action: item.action, key: item.key });
2809
+ if (!options.dryRun) {
2810
+ // Server-side concurrency guard (issue #1423 review r-2 P1): for an
2811
+ // update, send the `updatedAt` we last synced so the server rejects
2812
+ // the write (409 CONFLICT) if the var changed remotely since — this
2813
+ // closes the snapshot→PUT race the client-side guard can't. A
2814
+ // create has no baseline, so instead it sends a create-only
2815
+ // precondition (`expectNotExists`, review r-3 P1a): the server 409s
2816
+ // if the key was created remotely since our snapshot rather than
2817
+ // silently overwriting that concurrent create. `--force` overrides
2818
+ // both.
2819
+ const isCreate = item.action === "create";
2820
+ const expectedModifiedAt = options.force || isCreate
2821
+ ? undefined
2822
+ : syncState?.entities?.vars?.[item.key]?.modifiedAt;
2823
+ try {
2824
+ const result = await client.upsertAppConfigVar(resolvedAppId, item.key, { value: item.value }, expectedModifiedAt, { expectNotExists: !options.force && isCreate });
2825
+ if (syncState) {
2826
+ if (!syncState.entities.vars)
2827
+ syncState.entities.vars = {};
2828
+ syncState.entities.vars[item.key] = {
2829
+ // Record the server's returned timestamp (updatedAt) so the
2830
+ // next push's concurrent-edit guard has an accurate baseline.
2831
+ modifiedAt: result?.updatedAt || result?.modifiedAt || new Date().toISOString(),
2832
+ contentHash: item.valueHash,
2833
+ };
2834
+ }
2835
+ }
2836
+ catch (err) {
2837
+ // A remote edit landed since our snapshot — report a conflict
2838
+ // instead of clobbering it (feeds the shared accumulator →
2839
+ // `CONFLICT var: KEY`, non-zero exit).
2840
+ if (err instanceof ConflictError) {
2841
+ conflicts.push({
2842
+ type: "var",
2843
+ key: item.key,
2844
+ serverModifiedAt: err.serverModifiedAt,
2845
+ localModifiedAt: expectedModifiedAt || "unknown",
2846
+ });
2847
+ }
2848
+ else {
2849
+ throw wrapEntityError(err, item.action, "var", item.key);
2850
+ }
2851
+ }
2852
+ }
2853
+ }
2854
+ }
2305
2855
  // Process rule sets first (other entities may reference them by name)
2306
2856
  const ruleSetsDir = join(configDir, "rule-sets");
2307
2857
  if (existsSync(ruleSetsDir)) {
@@ -4982,6 +5532,31 @@ Directory Structure:
4982
5532
  localScripts.set(name, readFileSync(join(transformsDirPath, file), "utf-8"));
4983
5533
  }
4984
5534
  }
5535
+ // Config vars — issue #1423. Before this, `sync diff` ignored vars
5536
+ // entirely, so an add/remove/value-drift between the local `vars.toml`
5537
+ // and the server silently read as no difference right before a push
5538
+ // would change or unset them. Fetch the remote vars (value included —
5539
+ // vars are non-secret) and read the local `vars.toml` so the comparison
5540
+ // below can report added/removed/modified vars.
5541
+ let remoteVarItemsDiff = [];
5542
+ try {
5543
+ remoteVarItemsDiff = await client.listAppConfigVars(resolvedAppId);
5544
+ }
5545
+ catch {
5546
+ // Older server without the vars route — treat as no remote vars.
5547
+ }
5548
+ const remoteVars = new Map(remoteVarItemsDiff
5549
+ .filter((v) => typeof v?.key === "string")
5550
+ .map((v) => [v.key, String(v.value ?? "")]));
5551
+ const localVars = new Map();
5552
+ const varsTomlPathDiff = join(configDir, "vars.toml");
5553
+ if (existsSync(varsTomlPathDiff)) {
5554
+ const parsed = parseTomlFile(varsTomlPathDiff);
5555
+ for (const [key, rawValue] of Object.entries(parsed)) {
5556
+ if (typeof rawValue === "string")
5557
+ localVars.set(key, rawValue);
5558
+ }
5559
+ }
4985
5560
  // Compare
4986
5561
  const differences = [];
4987
5562
  // Integrations
@@ -5186,6 +5761,10 @@ Directory Structure:
5186
5761
  differences.push({ type: "transform", key: name, status: "remote only" });
5187
5762
  }
5188
5763
  }
5764
+ // Config vars — issue #1423 (see `diffVars`).
5765
+ for (const row of diffVars(localVars, remoteVars)) {
5766
+ differences.push(row);
5767
+ }
5189
5768
  // Compare test cases for synced prompts and workflows
5190
5769
  const testCaseDiffs = [];
5191
5770
  // Helper to compare test cases for a block