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.
- package/dist/src/commands/groups.js +9 -4
- package/dist/src/commands/groups.js.map +1 -1
- package/dist/src/commands/metadata.js +150 -0
- package/dist/src/commands/metadata.js.map +1 -1
- package/dist/src/commands/sync.d.ts +135 -0
- package/dist/src/commands/sync.js +579 -0
- package/dist/src/commands/sync.js.map +1 -1
- package/dist/src/commands/webhooks.js +10 -1
- package/dist/src/commands/webhooks.js.map +1 -1
- package/dist/src/commands/workflows.d.ts +30 -0
- package/dist/src/commands/workflows.js +273 -2
- package/dist/src/commands/workflows.js.map +1 -1
- package/dist/src/lib/api-client.d.ts +31 -2
- package/dist/src/lib/api-client.js +46 -7
- package/dist/src/lib/api-client.js.map +1 -1
- package/dist/src/lib/generated-allowlist.js +10 -0
- package/dist/src/lib/generated-allowlist.js.map +1 -1
- package/dist/src/types/index.d.ts +4 -0
- package/package.json +1 -1
|
@@ -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
|