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.
- package/dist/bin/primitive.js +2 -0
- package/dist/bin/primitive.js.map +1 -1
- package/dist/src/commands/apps.js +54 -2
- package/dist/src/commands/apps.js.map +1 -1
- package/dist/src/commands/databases.js +31 -10
- package/dist/src/commands/databases.js.map +1 -1
- package/dist/src/commands/documents.js +77 -0
- package/dist/src/commands/documents.js.map +1 -1
- package/dist/src/commands/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/settings.d.ts +15 -0
- package/dist/src/commands/settings.js +102 -0
- package/dist/src/commands/settings.js.map +1 -0
- package/dist/src/commands/sync-app-settings.d.ts +105 -0
- package/dist/src/commands/sync-app-settings.js +339 -0
- package/dist/src/commands/sync-app-settings.js.map +1 -0
- package/dist/src/commands/sync.d.ts +169 -19
- package/dist/src/commands/sync.js +942 -190
- 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 +305 -12
- package/dist/src/commands/workflows.js.map +1 -1
- package/dist/src/lib/api-client.d.ts +33 -2
- package/dist/src/lib/api-client.js +52 -7
- package/dist/src/lib/api-client.js.map +1 -1
- package/dist/src/lib/app-settings-descriptor.d.ts +108 -0
- package/dist/src/lib/app-settings-descriptor.js +250 -0
- package/dist/src/lib/app-settings-descriptor.js.map +1 -0
- package/dist/src/lib/codegen-shared/generatedFiles.d.ts +15 -0
- package/dist/src/lib/codegen-shared/generatedFiles.js +27 -10
- package/dist/src/lib/codegen-shared/generatedFiles.js.map +1 -1
- package/dist/src/lib/db-codegen/dbGenerator.d.ts +11 -83
- package/dist/src/lib/db-codegen/dbGenerator.js +93 -556
- package/dist/src/lib/db-codegen/dbGenerator.js.map +1 -1
- package/dist/src/lib/db-codegen/dbNaming.d.ts +15 -7
- package/dist/src/lib/db-codegen/dbNaming.js +25 -13
- package/dist/src/lib/db-codegen/dbNaming.js.map +1 -1
- package/dist/src/lib/db-codegen/dbTemplates.d.ts +55 -4
- package/dist/src/lib/db-codegen/dbTemplates.js +169 -40
- package/dist/src/lib/db-codegen/dbTemplates.js.map +1 -1
- package/dist/src/lib/db-codegen/dbTypeIR.d.ts +146 -0
- package/dist/src/lib/db-codegen/dbTypeIR.js +517 -0
- package/dist/src/lib/db-codegen/dbTypeIR.js.map +1 -0
- package/dist/src/lib/db-codegen/generated-operation-def-descriptor.d.ts +101 -0
- package/dist/src/lib/db-codegen/generated-operation-def-descriptor.js +200 -0
- package/dist/src/lib/db-codegen/generated-operation-def-descriptor.js.map +1 -0
- package/dist/src/lib/generated-allowlist.js +10 -0
- package/dist/src/lib/generated-allowlist.js.map +1 -1
- package/dist/src/lib/swift-codegen/dbGenerator.d.ts +68 -0
- package/dist/src/lib/swift-codegen/dbGenerator.js +380 -0
- package/dist/src/lib/swift-codegen/dbGenerator.js.map +1 -0
- package/dist/src/lib/swift-codegen/dbSwiftTypes.d.ts +42 -0
- package/dist/src/lib/swift-codegen/dbSwiftTypes.js +100 -0
- package/dist/src/lib/swift-codegen/dbSwiftTypes.js.map +1 -0
- package/dist/src/lib/swift-codegen/generator.d.ts +84 -0
- package/dist/src/lib/swift-codegen/generator.js +178 -0
- package/dist/src/lib/swift-codegen/generator.js.map +1 -0
- package/dist/src/lib/swift-codegen/schemaToSwift.d.ts +72 -0
- package/dist/src/lib/swift-codegen/schemaToSwift.js +644 -0
- package/dist/src/lib/swift-codegen/schemaToSwift.js.map +1 -0
- package/dist/src/lib/swift-codegen/swiftNaming.d.ts +85 -0
- package/dist/src/lib/swift-codegen/swiftNaming.js +198 -0
- package/dist/src/lib/swift-codegen/swiftNaming.js.map +1 -0
- package/dist/src/lib/sync-resource-types.d.ts +225 -0
- package/dist/src/lib/sync-resource-types.js +394 -0
- package/dist/src/lib/sync-resource-types.js.map +1 -0
- package/dist/src/lib/workflow-codegen/generator.d.ts +18 -5
- package/dist/src/lib/workflow-codegen/generator.js +116 -41
- package/dist/src/lib/workflow-codegen/generator.js.map +1 -1
- package/dist/src/lib/workflow-codegen/invokerIR.d.ts +85 -0
- package/dist/src/lib/workflow-codegen/invokerIR.js +71 -0
- package/dist/src/lib/workflow-codegen/invokerIR.js.map +1 -0
- package/dist/src/types/index.d.ts +4 -0
- 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
|
-
*
|
|
262
|
-
*
|
|
263
|
-
*
|
|
264
|
-
*
|
|
265
|
-
*
|
|
266
|
-
*
|
|
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
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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
|
-
|
|
283
|
-
|
|
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
|
-
|
|
286
|
-
|
|
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
|
|
331
|
+
return { write: true, content: serializeVars(outcome.vars), varEntities };
|
|
289
332
|
}
|
|
290
333
|
/**
|
|
291
|
-
*
|
|
292
|
-
*
|
|
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
|
-
*
|
|
295
|
-
*
|
|
296
|
-
*
|
|
297
|
-
*
|
|
298
|
-
*
|
|
299
|
-
*
|
|
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
|
|
302
|
-
const
|
|
303
|
-
const
|
|
304
|
-
|
|
305
|
-
|
|
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
|
-
|
|
309
|
-
|
|
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
|
-
|
|
312
|
-
|
|
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
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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 {
|
|
517
|
+
return { upserts, deletions, conflicts, skippedCount };
|
|
321
518
|
}
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
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 [
|
|
1589
|
-
client.listDatabaseTypeOperations(resolvedAppId, typeConfig.databaseType)
|
|
1590
|
-
client.listDatabaseTypeSubscriptions(resolvedAppId, typeConfig.databaseType)
|
|
1883
|
+
const [opsOutcome, subsOutcome] = await Promise.all([
|
|
1884
|
+
attemptFetch(() => client.listDatabaseTypeOperations(resolvedAppId, typeConfig.databaseType)),
|
|
1885
|
+
attemptFetch(() => client.listDatabaseTypeSubscriptions(resolvedAppId, typeConfig.databaseType)),
|
|
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
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
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
|
|
1703
|
-
const blobBucketItems =
|
|
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
|
-
//
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
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
|
|
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(
|
|
1969
|
-
blobBuckets: Object.keys(
|
|
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(
|
|
1973
|
-
emailTemplates: Object.keys(
|
|
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(
|
|
1976
|
-
ruleSets: Object.keys(
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
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(
|
|
1982
|
-
?
|
|
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
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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:
|
|
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 ===
|
|
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 ===
|
|
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
|