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.
- 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/guides.d.ts +15 -12
- package/dist/src/commands/guides.js +22 -15
- package/dist/src/commands/guides.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 +34 -19
- package/dist/src/commands/sync.js +373 -200
- package/dist/src/commands/sync.js.map +1 -1
- package/dist/src/commands/workflows.js +32 -10
- package/dist/src/commands/workflows.js.map +1 -1
- package/dist/src/lib/api-client.d.ts +2 -0
- package/dist/src/lib/api-client.js +6 -0
- 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/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/template.d.ts +1 -1
- package/dist/src/lib/template.js +6 -3
- package/dist/src/lib/template.js.map +1 -1
- 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/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
|
-
*
|
|
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
|
-
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
|
-
*
|
|
295
|
-
*
|
|
296
|
-
*
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
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 [
|
|
1895
|
-
client.listDatabaseTypeOperations(resolvedAppId, typeConfig.databaseType)
|
|
1896
|
-
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)),
|
|
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 :
|
|
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
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
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
|
|
2041
|
-
const blobBucketItems =
|
|
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
|
-
//
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
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
|
|
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(
|
|
2307
|
-
blobBuckets: Object.keys(
|
|
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(
|
|
2311
|
-
emailTemplates: Object.keys(
|
|
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(
|
|
2314
|
-
ruleSets: Object.keys(
|
|
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(
|
|
2317
|
-
collectionTypeConfigs: Object.keys(
|
|
2318
|
-
?
|
|
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(
|
|
2321
|
-
?
|
|
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
|
-
|
|
2691
|
-
|
|
2692
|
-
|
|
2693
|
-
|
|
2694
|
-
|
|
2695
|
-
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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:
|
|
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 ===
|
|
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 ===
|
|
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
|