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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/dist/bin/primitive.js +2 -0
  2. package/dist/bin/primitive.js.map +1 -1
  3. package/dist/src/commands/apps.js +54 -2
  4. package/dist/src/commands/apps.js.map +1 -1
  5. package/dist/src/commands/databases.js +31 -10
  6. package/dist/src/commands/databases.js.map +1 -1
  7. package/dist/src/commands/documents.js +77 -0
  8. package/dist/src/commands/documents.js.map +1 -1
  9. package/dist/src/commands/guides.d.ts +15 -12
  10. package/dist/src/commands/guides.js +22 -15
  11. package/dist/src/commands/guides.js.map +1 -1
  12. package/dist/src/commands/settings.d.ts +15 -0
  13. package/dist/src/commands/settings.js +102 -0
  14. package/dist/src/commands/settings.js.map +1 -0
  15. package/dist/src/commands/sync-app-settings.d.ts +105 -0
  16. package/dist/src/commands/sync-app-settings.js +339 -0
  17. package/dist/src/commands/sync-app-settings.js.map +1 -0
  18. package/dist/src/commands/sync.d.ts +34 -19
  19. package/dist/src/commands/sync.js +373 -200
  20. package/dist/src/commands/sync.js.map +1 -1
  21. package/dist/src/commands/workflows.js +32 -10
  22. package/dist/src/commands/workflows.js.map +1 -1
  23. package/dist/src/lib/api-client.d.ts +2 -0
  24. package/dist/src/lib/api-client.js +6 -0
  25. package/dist/src/lib/api-client.js.map +1 -1
  26. package/dist/src/lib/app-settings-descriptor.d.ts +108 -0
  27. package/dist/src/lib/app-settings-descriptor.js +250 -0
  28. package/dist/src/lib/app-settings-descriptor.js.map +1 -0
  29. package/dist/src/lib/codegen-shared/generatedFiles.d.ts +15 -0
  30. package/dist/src/lib/codegen-shared/generatedFiles.js +27 -10
  31. package/dist/src/lib/codegen-shared/generatedFiles.js.map +1 -1
  32. package/dist/src/lib/db-codegen/dbGenerator.d.ts +11 -83
  33. package/dist/src/lib/db-codegen/dbGenerator.js +93 -556
  34. package/dist/src/lib/db-codegen/dbGenerator.js.map +1 -1
  35. package/dist/src/lib/db-codegen/dbNaming.d.ts +15 -7
  36. package/dist/src/lib/db-codegen/dbNaming.js +25 -13
  37. package/dist/src/lib/db-codegen/dbNaming.js.map +1 -1
  38. package/dist/src/lib/db-codegen/dbTemplates.d.ts +55 -4
  39. package/dist/src/lib/db-codegen/dbTemplates.js +169 -40
  40. package/dist/src/lib/db-codegen/dbTemplates.js.map +1 -1
  41. package/dist/src/lib/db-codegen/dbTypeIR.d.ts +146 -0
  42. package/dist/src/lib/db-codegen/dbTypeIR.js +517 -0
  43. package/dist/src/lib/db-codegen/dbTypeIR.js.map +1 -0
  44. package/dist/src/lib/db-codegen/generated-operation-def-descriptor.d.ts +101 -0
  45. package/dist/src/lib/db-codegen/generated-operation-def-descriptor.js +200 -0
  46. package/dist/src/lib/db-codegen/generated-operation-def-descriptor.js.map +1 -0
  47. package/dist/src/lib/swift-codegen/dbGenerator.d.ts +68 -0
  48. package/dist/src/lib/swift-codegen/dbGenerator.js +380 -0
  49. package/dist/src/lib/swift-codegen/dbGenerator.js.map +1 -0
  50. package/dist/src/lib/swift-codegen/dbSwiftTypes.d.ts +42 -0
  51. package/dist/src/lib/swift-codegen/dbSwiftTypes.js +100 -0
  52. package/dist/src/lib/swift-codegen/dbSwiftTypes.js.map +1 -0
  53. package/dist/src/lib/swift-codegen/generator.d.ts +84 -0
  54. package/dist/src/lib/swift-codegen/generator.js +178 -0
  55. package/dist/src/lib/swift-codegen/generator.js.map +1 -0
  56. package/dist/src/lib/swift-codegen/schemaToSwift.d.ts +72 -0
  57. package/dist/src/lib/swift-codegen/schemaToSwift.js +644 -0
  58. package/dist/src/lib/swift-codegen/schemaToSwift.js.map +1 -0
  59. package/dist/src/lib/swift-codegen/swiftNaming.d.ts +85 -0
  60. package/dist/src/lib/swift-codegen/swiftNaming.js +198 -0
  61. package/dist/src/lib/swift-codegen/swiftNaming.js.map +1 -0
  62. package/dist/src/lib/sync-resource-types.d.ts +225 -0
  63. package/dist/src/lib/sync-resource-types.js +394 -0
  64. package/dist/src/lib/sync-resource-types.js.map +1 -0
  65. package/dist/src/lib/template.d.ts +1 -1
  66. package/dist/src/lib/template.js +6 -3
  67. package/dist/src/lib/template.js.map +1 -1
  68. package/dist/src/lib/workflow-codegen/generator.d.ts +18 -5
  69. package/dist/src/lib/workflow-codegen/generator.js +116 -41
  70. package/dist/src/lib/workflow-codegen/generator.js.map +1 -1
  71. package/dist/src/lib/workflow-codegen/invokerIR.d.ts +85 -0
  72. package/dist/src/lib/workflow-codegen/invokerIR.js +71 -0
  73. package/dist/src/lib/workflow-codegen/invokerIR.js.map +1 -0
  74. 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,109 +241,35 @@ export function hashRemoteWorkflowForDiff(workflow, draft, configs) {
238
241
  const parsed = parseConfigToml(serialized);
239
242
  return hashWorkflowTomlForDiff(parsed);
240
243
  }
241
- // Boolean [auth] keys in app.toml that map 1:1 to app-settings fields.
242
- const AUTH_BOOLEAN_KEYS = [
243
- "googleOAuthEnabled",
244
- "passkeyEnabled",
245
- "magicLinkEnabled",
246
- "appleSignInEnabled",
247
- "otpEnabled",
248
- ];
249
- // Every recognized [auth] key. Single source of truth shared by pull
250
- // serialization, push parsing, and the unrecognized-key warning so the three
251
- // never drift apart. `appleAudiences` is a string array and `passkeys` is the
252
- // passkey RP config object; the rest are booleans.
253
- const RECOGNIZED_AUTH_KEYS = new Set([
254
- ...AUTH_BOOLEAN_KEYS,
255
- "appleAudiences",
256
- "passkeys",
257
- ]);
258
244
  /**
259
245
  * Build the [auth] block of app.toml from server settings (pull direction).
260
246
  *
261
- * Apple/OTP fields round-trip alongside the legacy google/passkey/magic-link
262
- * keys. Two asymmetries are handled deliberately:
263
- * - `appleSignInEnabled` comes back as `boolean | null`; smol-toml throws on
264
- * null, so an unset value (null) is omitted rather than written.
265
- * - `appleAudiences` is omitted when empty. An empty array and an omitted key
266
- * both mean "no audiences" (the server normalizes [] -> null), so omitting
267
- * an empty list on pull is not lossy.
247
+ * Thin wrapper over the shared field descriptor
248
+ * (`cli/src/lib/app-settings-descriptor.ts`), which now owns the [auth] field
249
+ * set that `AUTH_BOOLEAN_KEYS`/`RECOGNIZED_AUTH_KEYS` used to hard-code. The
250
+ * descriptor drives all four sections in one place, so pull, push, and the
251
+ * unrecognized-key warning can never drift apart. `googleClientSecret` is a
252
+ * secret descriptor entry and is never written.
268
253
  */
269
254
  export function serializeAuthBlock(settings) {
270
- const auth = {
271
- googleOAuthEnabled: settings.googleOAuthEnabled,
272
- passkeyEnabled: settings.passkeyEnabled,
273
- magicLinkEnabled: settings.magicLinkEnabled,
274
- };
275
- if (typeof settings.appleSignInEnabled === "boolean") {
276
- auth.appleSignInEnabled = settings.appleSignInEnabled;
277
- }
278
- if (Array.isArray(settings.appleAudiences) &&
279
- settings.appleAudiences.length > 0) {
280
- auth.appleAudiences = settings.appleAudiences;
281
- }
282
- if (typeof settings.otpEnabled === "boolean") {
283
- auth.otpEnabled = settings.otpEnabled;
284
- }
285
- if (settings.passkeyRpConfig) {
286
- auth.passkeys = settings.passkeyRpConfig;
287
- }
288
- return auth;
255
+ return serializeSection(settings, "auth");
289
256
  }
290
257
  /**
291
258
  * Translate the [auth] block of app.toml into app-settings fields (push
292
- * direction).
293
- *
294
- * Only keys actually present in the TOML are forwarded an omitted key is left
295
- * untouched so it never overwrites server state (e.g. flipping a previously-true
296
- * flag to false). An explicit value, including `false`, is forwarded as-is.
297
- * `appleAudiences = []` is forwarded too; the server normalizes [] -> null
298
- * ("no audiences"). Keys outside RECOGNIZED_AUTH_KEYS produce a warning rather
299
- * than a hard error, so a typo or a future key doesn't silently drop.
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.
300
264
  */
301
265
  export function parseAppAuthSettings(auth) {
302
- const settings = {};
303
- const warnings = [];
304
- for (const key of AUTH_BOOLEAN_KEYS) {
305
- if (key in auth)
306
- settings[key] = auth[key];
307
- }
308
- if ("appleAudiences" in auth) {
309
- settings.appleAudiences = auth.appleAudiences;
310
- }
311
- if ("passkeys" in auth) {
312
- settings.passkeyRpConfig = auth.passkeys;
313
- }
314
- for (const key of Object.keys(auth)) {
315
- if (!RECOGNIZED_AUTH_KEYS.has(key)) {
316
- warnings.push(`Unrecognized [auth] key "${key}" in app.toml — ignored. ` +
317
- `Recognized keys: ${[...RECOGNIZED_AUTH_KEYS].join(", ")}.`);
318
- }
319
- }
320
- return { settings, warnings };
321
- }
322
- // TOML serialization helpers
323
- function serializeAppSettings(settings) {
324
- const data = {
325
- app: {
326
- name: settings.name,
327
- mode: settings.mode,
328
- waitlistEnabled: settings.waitlistEnabled,
329
- baseUrl: settings.baseUrl,
330
- },
331
- auth: serializeAuthBlock(settings),
332
- };
333
- if (settings.corsMode === "custom") {
334
- data.cors = {
335
- mode: settings.corsMode,
336
- allowedOrigins: settings.corsAllowedOrigins,
337
- allowCredentials: settings.corsAllowCredentials,
338
- allowedMethods: settings.corsAllowedMethods,
339
- maxAge: settings.corsMaxAge,
340
- };
341
- }
342
- return stringifyConfigToml(data);
266
+ const { settings, warnings, errors } = parseTomlToAppSettings({ auth });
267
+ return { settings, warnings: [...warnings, ...errors] };
343
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`).
344
273
  // --- Config vars (issue #1423) ---------------------------------------------
345
274
  // Per-environment non-secret scalars, round-tripped as a single flat
346
275
  // `vars.toml` at the sync-dir root (like app.toml). They bind as
@@ -1417,8 +1346,15 @@ export async function pullScripts(client, appId, configDir, logger = () => { })
1417
1346
  items = result.items || [];
1418
1347
  }
1419
1348
  catch {
1420
- return { scriptEntities, count: 0 };
1349
+ return {
1350
+ scriptEntities,
1351
+ count: 0,
1352
+ presence: { ok: false },
1353
+ listOk: false,
1354
+ serverKeys: [],
1355
+ };
1421
1356
  }
1357
+ let detailsOk = true;
1422
1358
  let written = 0;
1423
1359
  for (const script of items) {
1424
1360
  const name = script?.name;
@@ -1437,6 +1373,7 @@ export async function pullScripts(client, appId, configDir, logger = () => { })
1437
1373
  full = await client.getScript(appId, script.scriptId);
1438
1374
  }
1439
1375
  catch {
1376
+ detailsOk = false;
1440
1377
  logger(` Skipped transforms/${name}.rhai (could not fetch config)`);
1441
1378
  continue;
1442
1379
  }
@@ -1461,7 +1398,11 @@ export async function pullScripts(client, appId, configDir, logger = () => { })
1461
1398
  written += 1;
1462
1399
  logger(` Wrote transforms/${filename}`);
1463
1400
  }
1464
- return { scriptEntities, count: written };
1401
+ const serverKeys = items.map((s) => s?.name).filter(Boolean);
1402
+ const presence = detailsOk
1403
+ ? { ok: true, serverKeys }
1404
+ : { ok: false };
1405
+ return { scriptEntities, count: written, presence, listOk: true, serverKeys };
1465
1406
  }
1466
1407
  async function pullTestCasesForBlock(client, appId, blockType, blockId, blockKey, configDir, testCaseEntities, lookupMaps) {
1467
1408
  let testCases;
@@ -1827,6 +1768,7 @@ Directory Structure:
1827
1768
  .option("--app <app-id>", "App ID")
1828
1769
  .option("--dir <path>", "Config directory (overrides the auto-resolved per-env path)")
1829
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)")
1830
1772
  .action(async (appId, options) => {
1831
1773
  const resolvedAppId = resolveAppId(appId, options);
1832
1774
  const configDir = resolveSyncDir({ appId: resolvedAppId, userDir: options.dir });
@@ -1839,14 +1781,24 @@ Directory Structure:
1839
1781
  const client = new ApiClient();
1840
1782
  info(`Pulling configuration for app ${resolvedAppId}...`);
1841
1783
  try {
1842
- // Fetch all data
1843
- const [settings, integrationItems, promptItems, workflowItems, emailTemplatesResult] = await Promise.all([
1784
+ // Fetch all data. Issue #1659: prune decides whether a local file's
1785
+ // entity still exists server-side, so every fetch that establishes
1786
+ // presence must report success or failure explicitly — an error that
1787
+ // reads as an empty list would prune every local file of that type.
1788
+ // `integrations`/`prompts`/`workflows` throw straight out of the pull
1789
+ // on failure (no `.catch`), so reaching the prune pass at all proves
1790
+ // their listings succeeded. The types that swallow errors below carry
1791
+ // an explicit outcome instead.
1792
+ const [settings, integrationItems, promptItems, workflowItems, emailTemplatesOutcome] = await Promise.all([
1844
1793
  client.getAppSettings(resolvedAppId).catch(() => null),
1845
1794
  fetchAll((p) => client.listIntegrations(resolvedAppId, p)),
1846
1795
  fetchAll((p) => client.listPrompts(resolvedAppId, p)),
1847
1796
  fetchAll((p) => client.listWorkflows(resolvedAppId, p)),
1848
- client.listEmailTemplates(resolvedAppId).catch(() => ({ templates: [] })),
1797
+ attemptFetch(() => client.listEmailTemplates(resolvedAppId)),
1849
1798
  ]);
1799
+ const emailTemplatesResult = emailTemplatesOutcome.ok
1800
+ ? emailTemplatesOutcome.value
1801
+ : { templates: [] };
1850
1802
  // Fetch details for each entity
1851
1803
  const integrations = await Promise.all(integrationItems.map((i) => client.getIntegration(resolvedAppId, i.integrationId)));
1852
1804
  const prompts = await Promise.all(promptItems.map((p) => client.getPrompt(resolvedAppId, p.promptId)));
@@ -1871,30 +1823,71 @@ Directory Structure:
1871
1823
  }
1872
1824
  return workflowData;
1873
1825
  }));
1874
- // 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).
1875
1830
  const emailTemplateOverrides = emailTemplatesResult.templates.filter((t) => t.hasOverride);
1876
- const emailTemplates = await Promise.all(emailTemplateOverrides.map((t) => client.getEmailTemplate(resolvedAppId, t.emailType).catch(() => null))).then((results) => results.filter(Boolean));
1831
+ const emailTemplateOutcomes = await Promise.all(emailTemplateOverrides.map((t) => attemptFetch(() => client.getEmailTemplate(resolvedAppId, t.emailType))));
1832
+ const emailTemplates = emailTemplateOutcomes
1833
+ .map((o) => (o.ok ? o.value : null))
1834
+ .filter(Boolean);
1835
+ const emailTemplateDetailsOk = emailTemplateOutcomes.every((o) => o.ok && o.value);
1836
+ const emailTemplatePresence = emailTemplatesOutcome.ok && emailTemplateDetailsOk
1837
+ ? {
1838
+ ok: true,
1839
+ serverKeys: emailTemplateOverrides.map((t) => t.emailType),
1840
+ }
1841
+ : { ok: false };
1877
1842
  if (options.json) {
1878
1843
  json({ settings, integrations, prompts, workflows, emailTemplates });
1879
1844
  return;
1880
1845
  }
1881
1846
  // Fetch database config resources
1882
- const [databaseTypeConfigsResult, ruleSetsResult, groupTypeConfigsResult, collectionTypeConfigsResult, metadataCategoryConfigsResult,] = await Promise.all([
1883
- client.listDatabaseTypeConfigs(resolvedAppId).catch(() => []),
1884
- client.listRuleSets(resolvedAppId).catch(() => []),
1885
- client.listGroupTypeConfigs(resolvedAppId).catch(() => []),
1886
- client.listCollectionTypeConfigs(resolvedAppId).catch(() => []),
1887
- client.listMetadataCategoryConfigs(resolvedAppId).catch(() => []),
1847
+ // These five listings previously used `.catch(() => [])`, which made a
1848
+ // transient 401/500/network error indistinguishable from "the app has
1849
+ // none of these". Pull still tolerates the failure the same way (it
1850
+ // writes nothing for that type and carries on), but it now records the
1851
+ // outcome so the prune pass can skip a type whose presence is unknown
1852
+ // rather than delete every local file of it (#1659).
1853
+ const [databaseTypeConfigsOutcome, ruleSetsOutcome, groupTypeConfigsOutcome, collectionTypeConfigsOutcome, metadataCategoryConfigsOutcome,] = await Promise.all([
1854
+ attemptFetch(() => client.listDatabaseTypeConfigs(resolvedAppId)),
1855
+ attemptFetch(() => client.listRuleSets(resolvedAppId)),
1856
+ attemptFetch(() => client.listGroupTypeConfigs(resolvedAppId)),
1857
+ attemptFetch(() => client.listCollectionTypeConfigs(resolvedAppId)),
1858
+ attemptFetch(() => client.listMetadataCategoryConfigs(resolvedAppId)),
1888
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
+ : [];
1889
1873
  // Fetch operations + subscriptions for each database type. Issue #803:
1890
1874
  // subscriptions are pulled symmetrically with operations so a
1891
1875
  // pull → push cycle round-trips `[[subscriptions]]` blocks without a
1892
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;
1893
1882
  const databaseTypesWithOps = await Promise.all((Array.isArray(databaseTypeConfigsResult) ? databaseTypeConfigsResult : []).map(async (typeConfig) => {
1894
- const [ops, subs] = await Promise.all([
1895
- client.listDatabaseTypeOperations(resolvedAppId, typeConfig.databaseType).catch(() => []),
1896
- client.listDatabaseTypeSubscriptions(resolvedAppId, typeConfig.databaseType).catch(() => []),
1883
+ const [opsOutcome, subsOutcome] = await Promise.all([
1884
+ attemptFetch(() => client.listDatabaseTypeOperations(resolvedAppId, typeConfig.databaseType)),
1885
+ attemptFetch(() => client.listDatabaseTypeSubscriptions(resolvedAppId, typeConfig.databaseType)),
1897
1886
  ]);
1887
+ if (!opsOutcome.ok || !subsOutcome.ok)
1888
+ databaseTypeDetailsOk = false;
1889
+ const ops = opsOutcome.ok ? opsOutcome.value : [];
1890
+ const subs = subsOutcome.ok ? subsOutcome.value : [];
1898
1891
  return {
1899
1892
  typeConfig,
1900
1893
  operations: Array.isArray(ops) ? ops : [],
@@ -1945,6 +1938,12 @@ Directory Structure:
1945
1938
  ensureDir(join(configDir, "collection-type-configs"));
1946
1939
  ensureDir(join(configDir, "metadata-category-configs"));
1947
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 ?? {};
1948
1947
  // Write app settings
1949
1948
  if (settings) {
1950
1949
  const appTomlPath = join(configDir, "app.toml");
@@ -1971,7 +1970,7 @@ Directory Structure:
1971
1970
  catch (varErr) {
1972
1971
  varsOutcome = { ok: false, error: varErr };
1973
1972
  }
1974
- const varsPlan = planVarsPull(varsOutcome, varsOutcome.ok ? undefined : loadSyncState(configDir)?.entities?.vars);
1973
+ const varsPlan = planVarsPull(varsOutcome, varsOutcome.ok ? undefined : priorEntities.vars);
1975
1974
  const varEntities = varsPlan.varEntities;
1976
1975
  if (varsPlan.write && varsPlan.content !== null) {
1977
1976
  writeFileSync(varsTomlPathPull, varsPlan.content);
@@ -2013,15 +2012,13 @@ Directory Structure:
2013
2012
  };
2014
2013
  }
2015
2014
  info(` Pulled ${webhooks.length} webhook(s)`);
2016
- // Pull cron triggers
2017
- let cronTriggerItems = [];
2018
- try {
2019
- const cronResult = await client.listCronTriggers(resolvedAppId);
2020
- cronTriggerItems = cronResult.items || [];
2021
- }
2022
- catch {
2023
- // Cron triggers may not be available on older servers
2024
- }
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
+ : [];
2025
2022
  const cronTriggersDir = join(configDir, "cron-triggers");
2026
2023
  mkdirSync(cronTriggersDir, { recursive: true });
2027
2024
  const cronTriggerEntities = {};
@@ -2037,8 +2034,10 @@ Directory Structure:
2037
2034
  }
2038
2035
  info(` Pulled ${cronTriggerItems.length} cron trigger(s)`);
2039
2036
  // Pull blob buckets
2040
- const blobBucketsResult = await client.listBlobBuckets(resolvedAppId).catch(() => ({ items: [] }));
2041
- const blobBucketItems = blobBucketsResult.items || [];
2037
+ const blobBucketsOutcome = await attemptFetch(() => client.listBlobBuckets(resolvedAppId));
2038
+ const blobBucketItems = blobBucketsOutcome.ok
2039
+ ? blobBucketsOutcome.value.items || []
2040
+ : [];
2042
2041
  const blobBucketsDir = join(configDir, "blob-buckets");
2043
2042
  mkdirSync(blobBucketsDir, { recursive: true });
2044
2043
  const blobBucketEntities = {};
@@ -2091,7 +2090,7 @@ Directory Structure:
2091
2090
  // `transforms/<name>.rhai`, and record `{id, modifiedAt,
2092
2091
  // contentHash}` under `entities.scripts[name]` so a subsequent
2093
2092
  // `sync push` round-trips without false diffs.
2094
- 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);
2095
2094
  if (pulledScriptsCount > 0) {
2096
2095
  info(` Pulled ${pulledScriptsCount} transform(s)`);
2097
2096
  }
@@ -2143,19 +2142,10 @@ Directory Structure:
2143
2142
  };
2144
2143
  info(` Wrote email-templates/${filename}`);
2145
2144
  }
2146
- // Clean up local email template files for remotely-deleted overrides
2147
- const emailTemplatesDir = join(configDir, "email-templates");
2148
- if (existsSync(emailTemplatesDir)) {
2149
- const pulledTypes = new Set(emailTemplates.map((t) => t.emailType));
2150
- const localFiles = readdirSync(emailTemplatesDir).filter((f) => f.endsWith(".toml"));
2151
- for (const file of localFiles) {
2152
- const localType = basename(file, ".toml");
2153
- if (!pulledTypes.has(localType)) {
2154
- unlinkSync(join(emailTemplatesDir, file));
2155
- info(` Removed email-templates/${file} (override deleted on server)`);
2156
- }
2157
- }
2158
- }
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.
2159
2149
  // Pull test cases for prompts and workflows
2160
2150
  const testCaseEntities = {};
2161
2151
  let totalTestCases = 0;
@@ -2238,7 +2228,7 @@ Directory Structure:
2238
2228
  // Write rule sets
2239
2229
  const ruleSetEntities = {};
2240
2230
  for (const ruleSet of ruleSets) {
2241
- const fileKey = (ruleSet.name || ruleSet.ruleSetId).replace(/[/\\:*?"<>|]/g, "_");
2231
+ const fileKey = ruleSetFileKey(ruleSet);
2242
2232
  const filename = `${fileKey}.toml`;
2243
2233
  const filePath = join(configDir, "rule-sets", filename);
2244
2234
  writeFileSync(filePath, serializeRuleSet(ruleSet));
@@ -2294,6 +2284,182 @@ Directory Structure:
2294
2284
  };
2295
2285
  info(` Wrote metadata-category-configs/${filename}`);
2296
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
+ }
2297
2463
  // Save sync state
2298
2464
  const state = {
2299
2465
  appId: resolvedAppId,
@@ -2303,22 +2469,22 @@ Directory Structure:
2303
2469
  app: settings ? { modifiedAt: new Date().toISOString(), contentHash: computeFileHash(join(configDir, "app.toml")) } : undefined,
2304
2470
  integrations: integrationEntities,
2305
2471
  webhooks: webhookEntities,
2306
- cronTriggers: Object.keys(cronTriggerEntities).length > 0 ? cronTriggerEntities : undefined,
2307
- blobBuckets: Object.keys(blobBucketEntities).length > 0 ? blobBucketEntities : undefined,
2472
+ cronTriggers: Object.keys(finalCronTriggerEntities).length > 0 ? finalCronTriggerEntities : undefined,
2473
+ blobBuckets: Object.keys(finalBlobBucketEntities).length > 0 ? finalBlobBucketEntities : undefined,
2308
2474
  prompts: promptEntities,
2309
2475
  workflows: workflowEntities,
2310
- scripts: Object.keys(scriptEntities).length > 0 ? scriptEntities : undefined,
2311
- emailTemplates: Object.keys(emailTemplateEntities).length > 0 ? emailTemplateEntities : undefined,
2476
+ scripts: Object.keys(finalScriptEntities).length > 0 ? finalScriptEntities : undefined,
2477
+ emailTemplates: Object.keys(finalEmailTemplateEntities).length > 0 ? finalEmailTemplateEntities : undefined,
2312
2478
  testCases: Object.keys(testCaseEntities).length > 0 ? testCaseEntities : undefined,
2313
- databaseTypes: Object.keys(databaseTypeEntities).length > 0 ? databaseTypeEntities : undefined,
2314
- ruleSets: Object.keys(ruleSetEntities).length > 0 ? ruleSetEntities : undefined,
2479
+ databaseTypes: Object.keys(finalDatabaseTypeEntities).length > 0 ? finalDatabaseTypeEntities : undefined,
2480
+ ruleSets: Object.keys(finalRuleSetEntities).length > 0 ? finalRuleSetEntities : undefined,
2315
2481
  vars: Object.keys(varEntities).length > 0 ? varEntities : undefined,
2316
- groupTypeConfigs: Object.keys(groupTypeConfigEntities).length > 0 ? groupTypeConfigEntities : undefined,
2317
- collectionTypeConfigs: Object.keys(collectionTypeConfigEntities).length > 0
2318
- ? collectionTypeConfigEntities
2482
+ groupTypeConfigs: Object.keys(finalGroupTypeConfigEntities).length > 0 ? finalGroupTypeConfigEntities : undefined,
2483
+ collectionTypeConfigs: Object.keys(finalCollectionTypeConfigEntities).length > 0
2484
+ ? finalCollectionTypeConfigEntities
2319
2485
  : undefined,
2320
- metadataCategoryConfigs: Object.keys(metadataCategoryConfigEntities).length > 0
2321
- ? metadataCategoryConfigEntities
2486
+ metadataCategoryConfigs: Object.keys(finalMetadataCategoryConfigEntities).length > 0
2487
+ ? finalMetadataCategoryConfigEntities
2322
2488
  : undefined,
2323
2489
  },
2324
2490
  };
@@ -2340,6 +2506,7 @@ Directory Structure:
2340
2506
  keyValue("Group Type Configs", groupTypeConfigs.length);
2341
2507
  keyValue("Collection Type Configs", collectionTypeConfigs.length);
2342
2508
  keyValue("Metadata Category Configs", metadataCategoryConfigs.length);
2509
+ keyValue("Pruned", prunedCount);
2343
2510
  }
2344
2511
  catch (err) {
2345
2512
  error(err.message);
@@ -2591,6 +2758,25 @@ Directory Structure:
2591
2758
  }
2592
2759
  }
2593
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
+ }
2594
2780
  // Validate config vars up-front (issue #1423 review). Parse vars.toml
2595
2781
  // and check the same key/value constraints the server enforces here, in
2596
2782
  // the preflight pass, so an invalid entry aborts BEFORE any mutation
@@ -2687,27 +2873,13 @@ Directory Structure:
2687
2873
  }
2688
2874
  else {
2689
2875
  const tomlData = parseTomlFile(appTomlPath);
2690
- const settings = {};
2691
- if (tomlData.app) {
2692
- Object.assign(settings, tomlData.app);
2693
- // Don't overwrite the target app's name during cross-app push
2694
- if (isCrossAppPush) {
2695
- delete settings.name;
2696
- }
2697
- }
2698
- if (tomlData.auth) {
2699
- const { settings: authSettings, warnings: authWarnings } = parseAppAuthSettings(tomlData.auth);
2700
- Object.assign(settings, authSettings);
2701
- for (const w of authWarnings) {
2702
- warn(` ${w}`);
2703
- }
2704
- }
2705
- if (tomlData.cors) {
2706
- settings.corsMode = tomlData.cors.mode;
2707
- settings.corsAllowedOrigins = tomlData.cors.allowedOrigins;
2708
- settings.corsAllowCredentials = tomlData.cors.allowCredentials;
2709
- settings.corsAllowedMethods = tomlData.cors.allowedMethods;
2710
- 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}`);
2711
2883
  }
2712
2884
  changes.push({ type: "app", action: "update", key: "settings" });
2713
2885
  if (!options.dryRun) {
@@ -5559,10 +5731,18 @@ Directory Structure:
5559
5731
  }
5560
5732
  // Compare
5561
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);
5562
5742
  // Integrations
5563
5743
  for (const key of localIntegrations) {
5564
5744
  if (!remoteIntegrations.has(key)) {
5565
- differences.push({ type: "integration", key, status: "local only" });
5745
+ differences.push({ type: "integration", key, status: localOnlyStatus("integrations", key) });
5566
5746
  }
5567
5747
  else {
5568
5748
  differences.push({ type: "integration", key, status: "exists" });
@@ -5576,7 +5756,7 @@ Directory Structure:
5576
5756
  // Webhooks
5577
5757
  for (const key of localWebhooks) {
5578
5758
  if (!remoteWebhooks.has(key)) {
5579
- differences.push({ type: "webhook", key, status: "local only" });
5759
+ differences.push({ type: "webhook", key, status: localOnlyStatus("webhooks", key) });
5580
5760
  }
5581
5761
  else {
5582
5762
  differences.push({ type: "webhook", key, status: "exists" });
@@ -5590,7 +5770,7 @@ Directory Structure:
5590
5770
  // Cron Triggers
5591
5771
  for (const key of localCronTriggers) {
5592
5772
  if (!remoteCronTriggers.has(key)) {
5593
- differences.push({ type: "cron-trigger", key, status: "local only" });
5773
+ differences.push({ type: "cron-trigger", key, status: localOnlyStatus("cronTriggers", key) });
5594
5774
  }
5595
5775
  else {
5596
5776
  differences.push({ type: "cron-trigger", key, status: "exists" });
@@ -5604,7 +5784,7 @@ Directory Structure:
5604
5784
  // Blob Buckets
5605
5785
  for (const key of localBlobBuckets) {
5606
5786
  if (!remoteBlobBuckets.has(key)) {
5607
- differences.push({ type: "blob-bucket", key, status: "local only" });
5787
+ differences.push({ type: "blob-bucket", key, status: localOnlyStatus("blobBuckets", key) });
5608
5788
  }
5609
5789
  else {
5610
5790
  differences.push({ type: "blob-bucket", key, status: "exists" });
@@ -5618,7 +5798,7 @@ Directory Structure:
5618
5798
  // Prompts
5619
5799
  for (const key of localPrompts) {
5620
5800
  if (!remotePrompts.has(key)) {
5621
- differences.push({ type: "prompt", key, status: "local only" });
5801
+ differences.push({ type: "prompt", key, status: localOnlyStatus("prompts", key) });
5622
5802
  }
5623
5803
  else {
5624
5804
  differences.push({ type: "prompt", key, status: "exists" });
@@ -5650,7 +5830,7 @@ Directory Structure:
5650
5830
  const remoteWorkflowIds = new Map(workflowItems.map((w) => [w.workflowKey, w.workflowId]));
5651
5831
  for (const key of localWorkflows) {
5652
5832
  if (!remoteWorkflows.has(key)) {
5653
- differences.push({ type: "workflow", key, status: "local only" });
5833
+ differences.push({ type: "workflow", key, status: localOnlyStatus("workflows", key) });
5654
5834
  continue;
5655
5835
  }
5656
5836
  // Present on both sides → compare content.
@@ -5701,7 +5881,7 @@ Directory Structure:
5701
5881
  // Email Templates
5702
5882
  for (const key of localEmailTemplates) {
5703
5883
  if (!remoteEmailTemplates.has(key)) {
5704
- differences.push({ type: "email-template", key, status: "local only" });
5884
+ differences.push({ type: "email-template", key, status: localOnlyStatus("emailTemplates", key) });
5705
5885
  }
5706
5886
  else {
5707
5887
  differences.push({ type: "email-template", key, status: "exists" });
@@ -5722,7 +5902,7 @@ Directory Structure:
5722
5902
  for (const [name, localBody] of localScripts) {
5723
5903
  const remote = remoteScripts.get(name);
5724
5904
  if (!remote) {
5725
- differences.push({ type: "transform", key: name, status: "local only" });
5905
+ differences.push({ type: "transform", key: name, status: localOnlyStatus("scripts", name) });
5726
5906
  continue;
5727
5907
  }
5728
5908
  let status = "exists";
@@ -5789,7 +5969,7 @@ Directory Structure:
5789
5969
  }
5790
5970
  for (const slug of localTestSlugs) {
5791
5971
  if (!remoteTestNames.has(slug)) {
5792
- testCaseDiffs.push({ blockType, blockKey, slug, status: "local only" });
5972
+ testCaseDiffs.push({ blockType, blockKey, slug, status: LOCAL_ONLY_NEW });
5793
5973
  }
5794
5974
  else {
5795
5975
  testCaseDiffs.push({ blockType, blockKey, slug, status: "exists" });
@@ -5813,17 +5993,29 @@ Directory Structure:
5813
5993
  }
5814
5994
  }
5815
5995
  divider();
5816
- const localOnly = differences.filter((d) => d.status === "local only");
5996
+ const localOnly = differences.filter((d) => d.status === LOCAL_ONLY_NEW);
5997
+ const localOnlyAbsent = differences.filter((d) => d.status === LOCAL_ONLY_ABSENT);
5817
5998
  const remoteOnly = differences.filter((d) => d.status === "remote only");
5818
5999
  const modified = differences.filter((d) => d.status === "modified");
5819
6000
  const existing = differences.filter((d) => d.status === "exists");
5820
6001
  if (localOnly.length > 0) {
5821
- info("Local only (will be created on push):");
6002
+ info("Local only — new (will be created on push):");
5822
6003
  for (const d of localOnly) {
5823
6004
  console.log(` ${chalk.green("+")} ${d.type}: ${d.key}`);
5824
6005
  }
5825
6006
  console.log();
5826
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
+ }
5827
6019
  if (remoteOnly.length > 0) {
5828
6020
  warn("Remote only (not in local config):");
5829
6021
  for (const d of remoteOnly) {
@@ -5850,7 +6042,7 @@ Directory Structure:
5850
6042
  }
5851
6043
  }
5852
6044
  // Show test case differences
5853
- const tcLocalOnly = testCaseDiffs.filter((d) => d.status === "local only");
6045
+ const tcLocalOnly = testCaseDiffs.filter((d) => d.status === LOCAL_ONLY_NEW);
5854
6046
  const tcRemoteOnly = testCaseDiffs.filter((d) => d.status === "remote only");
5855
6047
  const tcSynced = testCaseDiffs.filter((d) => d.status === "exists");
5856
6048
  if (tcLocalOnly.length > 0 || tcRemoteOnly.length > 0) {
@@ -5867,7 +6059,8 @@ Directory Structure:
5867
6059
  }
5868
6060
  }
5869
6061
  divider();
5870
- keyValue("Local only", localOnly.length);
6062
+ keyValue("Local only (new)", localOnly.length);
6063
+ keyValue("Local only (absent from export)", localOnlyAbsent.length);
5871
6064
  keyValue("Remote only", remoteOnly.length);
5872
6065
  keyValue("Modified", modified.length);
5873
6066
  keyValue("Synced", existing.length);
@@ -6194,24 +6387,4 @@ Directory Structure:
6194
6387
  info("Run 'primitive sync diff' to inspect the restored state versus the server.");
6195
6388
  });
6196
6389
  }
6197
- /**
6198
- * Best-effort check for uncommitted git changes under `dir`. Used only to warn
6199
- * before a revert overwrites local edits — never fatal. Returns false if git
6200
- * isn't available, the dir isn't in a repo, or anything goes wrong.
6201
- */
6202
- async function hasUncommittedChanges(dir) {
6203
- if (!existsSync(dir))
6204
- return false;
6205
- try {
6206
- const { execSync } = await import("child_process");
6207
- const out = execSync(`git status --porcelain -- "${dir}"`, {
6208
- stdio: ["ignore", "pipe", "ignore"],
6209
- encoding: "utf-8",
6210
- });
6211
- return out.trim().length > 0;
6212
- }
6213
- catch {
6214
- return false;
6215
- }
6216
- }
6217
6390
  //# sourceMappingURL=sync.js.map