primitive-admin 1.0.49 → 1.0.51
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/README.md +129 -10
- package/assets/skill/skills/primitive-platform/SKILL.md +85 -30
- package/dist/bin/primitive.d.ts +2 -0
- package/dist/bin/primitive.js +66 -1
- package/dist/bin/primitive.js.map +1 -1
- package/dist/src/commands/admins.d.ts +2 -0
- package/dist/src/commands/admins.js +25 -27
- package/dist/src/commands/admins.js.map +1 -1
- package/dist/src/commands/analytics.d.ts +2 -0
- package/dist/src/commands/apps.d.ts +2 -0
- package/dist/src/commands/apps.js +28 -0
- package/dist/src/commands/apps.js.map +1 -1
- package/dist/src/commands/auth.d.ts +2 -0
- package/dist/src/commands/blob-buckets.d.ts +2 -0
- package/dist/src/commands/blob-buckets.js +30 -26
- package/dist/src/commands/blob-buckets.js.map +1 -1
- package/dist/src/commands/catalog.d.ts +2 -0
- package/dist/src/commands/catalog.js +17 -18
- package/dist/src/commands/catalog.js.map +1 -1
- package/dist/src/commands/collection-type-configs.d.ts +2 -0
- package/dist/src/commands/collection-type-configs.js +9 -9
- package/dist/src/commands/collection-type-configs.js.map +1 -1
- package/dist/src/commands/collections.d.ts +2 -0
- package/dist/src/commands/collections.js +33 -36
- package/dist/src/commands/collections.js.map +1 -1
- package/dist/src/commands/comparisons.d.ts +2 -0
- package/dist/src/commands/cron-triggers.d.ts +2 -0
- package/dist/src/commands/cron-triggers.js +8 -15
- package/dist/src/commands/cron-triggers.js.map +1 -1
- package/dist/src/commands/database-types.d.ts +2 -0
- package/dist/src/commands/database-types.js +17 -18
- package/dist/src/commands/database-types.js.map +1 -1
- package/dist/src/commands/databases.d.ts +2 -0
- package/dist/src/commands/databases.js +72 -45
- package/dist/src/commands/databases.js.map +1 -1
- package/dist/src/commands/documents.d.ts +2 -0
- package/dist/src/commands/documents.js +17 -18
- package/dist/src/commands/documents.js.map +1 -1
- package/dist/src/commands/email-templates.d.ts +2 -0
- package/dist/src/commands/email-templates.js +9 -9
- package/dist/src/commands/email-templates.js.map +1 -1
- package/dist/src/commands/env.d.ts +12 -0
- package/dist/src/commands/group-type-configs.d.ts +2 -0
- package/dist/src/commands/group-type-configs.js +9 -9
- package/dist/src/commands/group-type-configs.js.map +1 -1
- package/dist/src/commands/groups.d.ts +2 -0
- package/dist/src/commands/groups.js +17 -18
- package/dist/src/commands/groups.js.map +1 -1
- package/dist/src/commands/guides.d.ts +84 -0
- package/dist/src/commands/guides.js +201 -24
- package/dist/src/commands/guides.js.map +1 -1
- package/dist/src/commands/init.d.ts +17 -0
- package/dist/src/commands/init.js +63 -25
- package/dist/src/commands/init.js.map +1 -1
- package/dist/src/commands/integrations.d.ts +2 -0
- package/dist/src/commands/integrations.js +39 -23
- package/dist/src/commands/integrations.js.map +1 -1
- package/dist/src/commands/llm.d.ts +2 -0
- package/dist/src/commands/llm.js +4 -2
- package/dist/src/commands/llm.js.map +1 -1
- package/dist/src/commands/prompts.d.ts +2 -0
- package/dist/src/commands/prompts.js +33 -36
- package/dist/src/commands/prompts.js.map +1 -1
- package/dist/src/commands/rule-sets.d.ts +2 -0
- package/dist/src/commands/rule-sets.js +9 -9
- package/dist/src/commands/rule-sets.js.map +1 -1
- package/dist/src/commands/secrets.d.ts +2 -0
- package/dist/src/commands/skill.d.ts +2 -0
- package/dist/src/commands/sync.d.ts +99 -0
- package/dist/src/commands/sync.js +437 -31
- package/dist/src/commands/sync.js.map +1 -1
- package/dist/src/commands/tokens.d.ts +2 -0
- package/dist/src/commands/tokens.js +113 -10
- package/dist/src/commands/tokens.js.map +1 -1
- package/dist/src/commands/users.d.ts +2 -0
- package/dist/src/commands/users.js +41 -45
- package/dist/src/commands/users.js.map +1 -1
- package/dist/src/commands/waitlist.d.ts +2 -0
- package/dist/src/commands/waitlist.js +10 -10
- package/dist/src/commands/waitlist.js.map +1 -1
- package/dist/src/commands/webhooks.d.ts +2 -0
- package/dist/src/commands/webhooks.js +9 -9
- package/dist/src/commands/webhooks.js.map +1 -1
- package/dist/src/commands/workflows.d.ts +49 -0
- package/dist/src/commands/workflows.js +136 -57
- package/dist/src/commands/workflows.js.map +1 -1
- package/dist/src/lib/api-client.d.ts +1229 -0
- package/dist/src/lib/api-client.js +44 -11
- package/dist/src/lib/api-client.js.map +1 -1
- package/dist/src/lib/auth-flow.d.ts +8 -0
- package/dist/src/lib/cli-manifest.d.ts +60 -0
- package/dist/src/lib/cli-manifest.js +70 -0
- package/dist/src/lib/cli-manifest.js.map +1 -0
- package/dist/src/lib/config.d.ts +37 -0
- package/dist/src/lib/confirm-prompt.d.ts +83 -0
- package/dist/src/lib/confirm-prompt.js +110 -0
- package/dist/src/lib/confirm-prompt.js.map +1 -0
- package/dist/src/lib/constants.d.ts +2 -0
- package/dist/src/lib/crash-handlers.d.ts +20 -0
- package/dist/src/lib/crash-handlers.js +49 -0
- package/dist/src/lib/crash-handlers.js.map +1 -0
- package/dist/src/lib/credentials-store.d.ts +79 -0
- package/dist/src/lib/csv.d.ts +48 -0
- package/dist/src/lib/db-codegen/dbFingerprint.d.ts +10 -0
- package/dist/src/lib/db-codegen/dbGenerator.d.ts +111 -0
- package/dist/src/lib/db-codegen/dbNaming.d.ts +45 -0
- package/dist/src/lib/db-codegen/dbTemplates.d.ts +97 -0
- package/dist/src/lib/db-codegen/dbTemplates.js +31 -10
- package/dist/src/lib/db-codegen/dbTemplates.js.map +1 -1
- package/dist/src/lib/db-codegen/dbTsTypes.d.ts +78 -0
- package/dist/src/lib/db-codegen/dbTsTypes.js +2 -2
- package/dist/src/lib/db-codegen/dbTsTypes.js.map +1 -1
- package/dist/src/lib/env-resolver.d.ts +62 -0
- package/dist/src/lib/fetch.d.ts +5 -0
- package/dist/src/lib/generated-allowlist.d.ts +28 -0
- package/dist/src/lib/generated-allowlist.js +181 -0
- package/dist/src/lib/generated-allowlist.js.map +1 -0
- package/dist/src/lib/init-config.d.ts +46 -0
- package/dist/src/lib/init-config.js +7 -0
- package/dist/src/lib/init-config.js.map +1 -1
- package/dist/src/lib/migration-nag.d.ts +49 -0
- package/dist/src/lib/output.d.ts +49 -0
- package/dist/src/lib/output.js +25 -1
- package/dist/src/lib/output.js.map +1 -1
- package/dist/src/lib/paginate.d.ts +33 -0
- package/dist/src/lib/project-config.d.ts +97 -0
- package/dist/src/lib/refresh-admin-credentials.d.ts +65 -0
- package/dist/src/lib/resolve-platform.d.ts +45 -0
- package/dist/src/lib/resolve-platform.js +43 -0
- package/dist/src/lib/resolve-platform.js.map +1 -0
- package/dist/src/lib/skill-installer.d.ts +23 -0
- package/dist/src/lib/snapshots.d.ts +99 -0
- package/dist/src/lib/snapshots.js +357 -0
- package/dist/src/lib/snapshots.js.map +1 -0
- package/dist/src/lib/sync-paths.d.ts +72 -0
- package/dist/src/lib/sync-paths.js +29 -1
- package/dist/src/lib/sync-paths.js.map +1 -1
- package/dist/src/lib/template.d.ts +93 -0
- package/dist/src/lib/token-inject.d.ts +56 -0
- package/dist/src/lib/token-inject.js +204 -0
- package/dist/src/lib/token-inject.js.map +1 -0
- package/dist/src/lib/toml-database-config.d.ts +132 -0
- package/dist/src/lib/toml-params-validator.d.ts +95 -0
- package/dist/src/lib/version-check.d.ts +10 -0
- package/dist/src/lib/workflow-fragments.d.ts +41 -0
- package/dist/src/lib/workflow-toml-validator.d.ts +95 -0
- package/dist/src/lib/workflow-toml-validator.js +71 -130
- package/dist/src/lib/workflow-toml-validator.js.map +1 -1
- package/dist/src/types/index.d.ts +513 -0
- package/dist/src/validators.d.ts +64 -0
- package/dist/src/validators.js +63 -0
- package/dist/src/validators.js.map +1 -0
- package/package.json +10 -1
|
@@ -7,16 +7,31 @@ import { ApiClient, ApiError, ConflictError, SchemaRequiredError, OperationRefEr
|
|
|
7
7
|
import { buildDatabaseTypeTomlData, detectExistingOperationForms, normalizeOperationFromToml, normalizeSubscriptionFromToml, SubscriptionAccessKeyConflictError, } from "../lib/toml-database-config.js";
|
|
8
8
|
import { validateOperations, formatIssue, } from "../lib/toml-params-validator.js";
|
|
9
9
|
import { getServerUrl, resolveAppId } from "../lib/config.js";
|
|
10
|
-
import { resolveSyncDir, isAutoResolvedSyncDir, checkLegacySyncMigration, } from "../lib/sync-paths.js";
|
|
10
|
+
import { resolveSyncDir, resolveSnapshotsRoot, isAutoResolvedSyncDir, checkLegacySyncMigration, } from "../lib/sync-paths.js";
|
|
11
|
+
import { createSnapshot, listSnapshots, resolveSnapshot, restoreSnapshot, pruneSnapshots, } from "../lib/snapshots.js";
|
|
11
12
|
import { validateWorkflowToml, formatWorkflowTomlErrors, } from "../lib/workflow-toml-validator.js";
|
|
12
13
|
import { expandWorkflowTomlData } from "../lib/workflow-fragments.js";
|
|
13
14
|
import { success, error, info, warn, keyValue, json, divider, } from "../lib/output.js";
|
|
15
|
+
import { confirmPrompt } from "../lib/confirm-prompt.js";
|
|
14
16
|
import chalk from "chalk";
|
|
15
17
|
function ensureDir(dirPath) {
|
|
16
18
|
if (!existsSync(dirPath)) {
|
|
17
19
|
mkdirSync(dirPath, { recursive: true });
|
|
18
20
|
}
|
|
19
21
|
}
|
|
22
|
+
/**
|
|
23
|
+
* Issue #974: when a type-config push newly declares fields `unique = true`
|
|
24
|
+
* and the type already has instances, the server returns a
|
|
25
|
+
* `staleInstanceWarning`. Surface it so the operator knows the existing
|
|
26
|
+
* instances still lack the index and how to back-provision them. The push
|
|
27
|
+
* itself still succeeded — this is informational, not a failure.
|
|
28
|
+
*/
|
|
29
|
+
function printStaleInstanceWarning(updated) {
|
|
30
|
+
const w = updated?.staleInstanceWarning;
|
|
31
|
+
if (w && typeof w.message === "string") {
|
|
32
|
+
warn(` ${w.message}`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
20
35
|
function loadSyncState(configDir) {
|
|
21
36
|
const syncFile = join(configDir, ".primitive-sync.json");
|
|
22
37
|
if (!existsSync(syncFile)) {
|
|
@@ -246,7 +261,13 @@ function serializeBlobBucket(bucket) {
|
|
|
246
261
|
name: bucket.name,
|
|
247
262
|
description: bucket.description || undefined,
|
|
248
263
|
ttlTier: bucket.ttlTier,
|
|
249
|
-
|
|
264
|
+
// #1020: `preset` is the honest key; a custom bucket carries ruleSetId
|
|
265
|
+
// instead. Omit preset for custom buckets (preset === "custom").
|
|
266
|
+
preset: bucket.preset && bucket.preset !== "custom"
|
|
267
|
+
? bucket.preset
|
|
268
|
+
: bucket.ruleSetId
|
|
269
|
+
? undefined
|
|
270
|
+
: bucket.preset,
|
|
250
271
|
},
|
|
251
272
|
};
|
|
252
273
|
if (bucket.ruleSetId) {
|
|
@@ -299,6 +320,9 @@ function serializeWorkflow(workflow, draft, configs) {
|
|
|
299
320
|
status: workflow.status,
|
|
300
321
|
activeConfigName: activeConfigName,
|
|
301
322
|
accessRule: workflow.accessRule || undefined,
|
|
323
|
+
// #1081 — workflow principal mode. Emit only when set so a fresh
|
|
324
|
+
// pull → push round-trips it without writing a noisy `runAs = ""`.
|
|
325
|
+
runAs: workflow.runAs || undefined,
|
|
302
326
|
perUserMaxRunning: workflow.perUserMaxRunning,
|
|
303
327
|
perUserMaxQueued: workflow.perUserMaxQueued,
|
|
304
328
|
dequeueOrder: workflow.dequeueOrder,
|
|
@@ -1153,6 +1177,35 @@ Directory Structure:
|
|
|
1153
1177
|
subscriptions: Array.isArray(subs) ? subs : [],
|
|
1154
1178
|
};
|
|
1155
1179
|
}));
|
|
1180
|
+
// Snapshot the pre-pull sync tree BEFORE we touch any local file
|
|
1181
|
+
// (issue #578, Phase 1). All server data has been fetched and
|
|
1182
|
+
// validated at this point — a failed pull already threw above, so we
|
|
1183
|
+
// never create empty snapshots — but no local file has been
|
|
1184
|
+
// overwritten yet. This is the proven copy-point: between the last
|
|
1185
|
+
// server fetch and the first directory mutation.
|
|
1186
|
+
//
|
|
1187
|
+
// Fail LOUD: if the snapshot can't be written, abort the pull BEFORE
|
|
1188
|
+
// the ensureDir below. Better to refuse a destructive pull than
|
|
1189
|
+
// perform it without a recoverable backup. No-op on a fresh/empty
|
|
1190
|
+
// sync dir (nothing to back up). Prune to 28 days after success.
|
|
1191
|
+
try {
|
|
1192
|
+
const snapshotsRoot = resolveSnapshotsRoot({
|
|
1193
|
+
appId: resolvedAppId,
|
|
1194
|
+
userDir: options.dir,
|
|
1195
|
+
});
|
|
1196
|
+
const snapshot = createSnapshot(configDir, snapshotsRoot);
|
|
1197
|
+
if (snapshot) {
|
|
1198
|
+
info(` Snapshot: ${snapshot.path}`);
|
|
1199
|
+
pruneSnapshots(snapshotsRoot, 28);
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
catch (snapErr) {
|
|
1203
|
+
error(`Failed to create a pre-pull snapshot: ${snapErr?.message ?? snapErr}`);
|
|
1204
|
+
error("Aborting pull before any local file is modified. " +
|
|
1205
|
+
"Resolve the snapshot error (e.g. disk space / permissions) and retry, " +
|
|
1206
|
+
"or pass a writable --dir.");
|
|
1207
|
+
process.exit(1);
|
|
1208
|
+
}
|
|
1156
1209
|
// Ensure directories exist
|
|
1157
1210
|
ensureDir(configDir);
|
|
1158
1211
|
ensureDir(join(configDir, "integrations"));
|
|
@@ -1660,6 +1713,82 @@ Directory Structure:
|
|
|
1660
1713
|
const promptKeyToId = new Map();
|
|
1661
1714
|
const promptConfigNameToId = new Map(); // key: "promptKey#configName"
|
|
1662
1715
|
const workflowConfigNameToId = new Map(); // key: "workflowKey#configName"
|
|
1716
|
+
// ── Issue #976 (fix A): front-load ALL client-side TOML validation
|
|
1717
|
+
// before the first mutating call. Push is apply-as-you-go and processes
|
|
1718
|
+
// entities in a fixed sequence (cron triggers are created *before*
|
|
1719
|
+
// workflows are validated). Previously each entity's validation lived
|
|
1720
|
+
// inside its own apply loop and aborted via `process.exit(1)` — so a
|
|
1721
|
+
// known-bad sibling file (e.g. an invalid workflow TOML) could let an
|
|
1722
|
+
// earlier create (a cron trigger) be applied, then abort. The
|
|
1723
|
+
// `process.exit` also bypassed the save-on-failure catch below, so the
|
|
1724
|
+
// orphaned create never reached `.primitive-sync.json` and the retry
|
|
1725
|
+
// re-issued a CREATE that the server rejected with 409 forever.
|
|
1726
|
+
//
|
|
1727
|
+
// Running every validation up-front means a known-bad file aborts
|
|
1728
|
+
// BEFORE anything is created. We collect all errors (not just the first)
|
|
1729
|
+
// and THROW — so the single save-on-failure catch is the only exit path
|
|
1730
|
+
// (no more `process.exit` bypass class). The per-loop validators below
|
|
1731
|
+
// remain as defense-in-depth but are converted to throws too.
|
|
1732
|
+
const preflightValidationErrors = [];
|
|
1733
|
+
// Validate all workflow TOMLs (issue #685 misnested-header check).
|
|
1734
|
+
const preflightWorkflowsDir = join(configDir, "workflows");
|
|
1735
|
+
if (existsSync(preflightWorkflowsDir)) {
|
|
1736
|
+
for (const file of readdirSync(preflightWorkflowsDir).filter((f) => f.endsWith(".toml"))) {
|
|
1737
|
+
const filePath = join(preflightWorkflowsDir, file);
|
|
1738
|
+
try {
|
|
1739
|
+
const tomlData = parseTomlFile(filePath);
|
|
1740
|
+
const tomlErrors = validateWorkflowToml(tomlData);
|
|
1741
|
+
if (tomlErrors.length > 0) {
|
|
1742
|
+
preflightValidationErrors.push(formatWorkflowTomlErrors(filePath, tomlErrors));
|
|
1743
|
+
}
|
|
1744
|
+
}
|
|
1745
|
+
catch (err) {
|
|
1746
|
+
preflightValidationErrors.push(` ${file}: ${err?.message || String(err)}`);
|
|
1747
|
+
}
|
|
1748
|
+
}
|
|
1749
|
+
}
|
|
1750
|
+
// Validate all database-type TOMLs (issue #803 subscription
|
|
1751
|
+
// access/accessRule conflict + issue #752 $params references).
|
|
1752
|
+
const preflightDbTypesDir = join(configDir, "database-types");
|
|
1753
|
+
if (existsSync(preflightDbTypesDir)) {
|
|
1754
|
+
for (const file of readdirSync(preflightDbTypesDir).filter((f) => f.endsWith(".toml"))) {
|
|
1755
|
+
const filePath = join(preflightDbTypesDir, file);
|
|
1756
|
+
let rawToml;
|
|
1757
|
+
let tomlData;
|
|
1758
|
+
try {
|
|
1759
|
+
rawToml = readFileSync(filePath, "utf-8");
|
|
1760
|
+
tomlData = parseTomlFile(filePath);
|
|
1761
|
+
}
|
|
1762
|
+
catch (err) {
|
|
1763
|
+
preflightValidationErrors.push(` ${file}: ${err?.message || String(err)}`);
|
|
1764
|
+
continue;
|
|
1765
|
+
}
|
|
1766
|
+
let operations = [];
|
|
1767
|
+
try {
|
|
1768
|
+
({ operations } = parseDatabaseTypeToml(tomlData));
|
|
1769
|
+
}
|
|
1770
|
+
catch (err) {
|
|
1771
|
+
if (err instanceof SubscriptionAccessKeyConflictError) {
|
|
1772
|
+
preflightValidationErrors.push(` ${file}: ${err.message}`);
|
|
1773
|
+
continue;
|
|
1774
|
+
}
|
|
1775
|
+
preflightValidationErrors.push(` ${file}: ${err?.message || String(err)}`);
|
|
1776
|
+
continue;
|
|
1777
|
+
}
|
|
1778
|
+
const validation = validateOperations({
|
|
1779
|
+
filePath,
|
|
1780
|
+
rawToml,
|
|
1781
|
+
operations,
|
|
1782
|
+
});
|
|
1783
|
+
for (const e of validation.errors) {
|
|
1784
|
+
preflightValidationErrors.push(` ${formatIssue(e)}`);
|
|
1785
|
+
}
|
|
1786
|
+
}
|
|
1787
|
+
}
|
|
1788
|
+
if (preflightValidationErrors.length > 0) {
|
|
1789
|
+
throw new Error(`Aborting push: ${preflightValidationErrors.length} TOML validation error(s) — no changes were applied.\n` +
|
|
1790
|
+
preflightValidationErrors.join("\n"));
|
|
1791
|
+
}
|
|
1663
1792
|
// Process app settings
|
|
1664
1793
|
const appTomlPath = join(configDir, "app.toml");
|
|
1665
1794
|
if (existsSync(appTomlPath)) {
|
|
@@ -2031,7 +2160,58 @@ Directory Structure:
|
|
|
2031
2160
|
}
|
|
2032
2161
|
}
|
|
2033
2162
|
catch (err) {
|
|
2034
|
-
|
|
2163
|
+
// Issue #976 (fix B): idempotent create — adopt-by-key on 409.
|
|
2164
|
+
// A cron trigger can be orphaned on the server (created by a
|
|
2165
|
+
// prior push that aborted before recording it in sync state,
|
|
2166
|
+
// a mid-apply crash, or an out-of-band create with the same
|
|
2167
|
+
// key). On retry this CREATE path then hits the
|
|
2168
|
+
// `triggerKeyPerApp` unique constraint and the server returns
|
|
2169
|
+
// 409 "A cron trigger with this key already exists". Rather
|
|
2170
|
+
// than hard-fail forever, look up the existing trigger by key
|
|
2171
|
+
// (the list endpoint is app-scoped, so every item is owned by
|
|
2172
|
+
// this app), verify the SAME triggerKey, adopt its id into
|
|
2173
|
+
// sync state, and re-issue as an UPDATE so the push converges.
|
|
2174
|
+
const msg = String(err?.message || err);
|
|
2175
|
+
const is409 = err?.statusCode === 409 || msg.includes("already exists");
|
|
2176
|
+
if (is409) {
|
|
2177
|
+
info(` Cron trigger already exists on server, adopting by key: ${key}`);
|
|
2178
|
+
let adoptedId;
|
|
2179
|
+
try {
|
|
2180
|
+
const { items } = await client.listCronTriggers(resolvedAppId);
|
|
2181
|
+
// Verify same key + app ownership: the list endpoint only
|
|
2182
|
+
// returns triggers for `resolvedAppId`, so a triggerKey
|
|
2183
|
+
// match is also an ownership match. Never overwrite an
|
|
2184
|
+
// unrelated resource — require an exact key equality.
|
|
2185
|
+
const existing = (items || []).find((t) => t?.triggerKey === key);
|
|
2186
|
+
if (existing?.triggerId) {
|
|
2187
|
+
adoptedId = existing.triggerId;
|
|
2188
|
+
}
|
|
2189
|
+
}
|
|
2190
|
+
catch (lookupErr) {
|
|
2191
|
+
throw wrapEntityError(new Error(`cron trigger "${key}" already exists but could not be adopted (lookup failed: ${String(lookupErr?.message || lookupErr)})`), "create", "cron trigger", key);
|
|
2192
|
+
}
|
|
2193
|
+
if (!adoptedId) {
|
|
2194
|
+
// 409 but no matching key found on the server — surface
|
|
2195
|
+
// the original error rather than silently overwriting.
|
|
2196
|
+
throw wrapEntityError(err, "create", "cron trigger", key);
|
|
2197
|
+
}
|
|
2198
|
+
// Switch to UPDATE on the adopted trigger.
|
|
2199
|
+
const updated = await client.updateCronTrigger(resolvedAppId, adoptedId, payload);
|
|
2200
|
+
info(` Adopted + updated cron trigger: ${key}`);
|
|
2201
|
+
if (syncState) {
|
|
2202
|
+
if (!syncState.entities.cronTriggers) {
|
|
2203
|
+
syncState.entities.cronTriggers = {};
|
|
2204
|
+
}
|
|
2205
|
+
syncState.entities.cronTriggers[key] = {
|
|
2206
|
+
id: adoptedId,
|
|
2207
|
+
modifiedAt: updated?.modifiedAt || new Date().toISOString(),
|
|
2208
|
+
contentHash: computeFileHash(filePath),
|
|
2209
|
+
};
|
|
2210
|
+
}
|
|
2211
|
+
}
|
|
2212
|
+
else {
|
|
2213
|
+
throw wrapEntityError(err, "create", "cron trigger", key);
|
|
2214
|
+
}
|
|
2035
2215
|
}
|
|
2036
2216
|
}
|
|
2037
2217
|
}
|
|
@@ -2053,8 +2233,29 @@ Directory Structure:
|
|
|
2053
2233
|
continue;
|
|
2054
2234
|
}
|
|
2055
2235
|
if (existingId) {
|
|
2056
|
-
//
|
|
2057
|
-
|
|
2236
|
+
// #1020 (D8): idempotently apply preset/ruleSet/name/description
|
|
2237
|
+
// changes via PATCH. bucketKey/ttlTier are immutable and not sent.
|
|
2238
|
+
const updatePayload = {};
|
|
2239
|
+
if (bucket.preset)
|
|
2240
|
+
updatePayload.preset = bucket.preset;
|
|
2241
|
+
else if (bucket.accessPolicy)
|
|
2242
|
+
updatePayload.accessPolicy = bucket.accessPolicy;
|
|
2243
|
+
if (bucket.ruleSetId)
|
|
2244
|
+
updatePayload.ruleSetId = bucket.ruleSetId;
|
|
2245
|
+
if (bucket.name)
|
|
2246
|
+
updatePayload.name = bucket.name;
|
|
2247
|
+
if (bucket.description !== undefined)
|
|
2248
|
+
updatePayload.description = bucket.description || null;
|
|
2249
|
+
changes.push({ type: "blob-bucket", action: "update", key });
|
|
2250
|
+
if (!options.dryRun) {
|
|
2251
|
+
try {
|
|
2252
|
+
await client.updateBlobBucket(resolvedAppId, existingId, updatePayload);
|
|
2253
|
+
info(` Updated blob bucket: ${key}`);
|
|
2254
|
+
}
|
|
2255
|
+
catch (err) {
|
|
2256
|
+
warn(` Failed to update blob bucket ${key}: ${String(err?.message || err)}`);
|
|
2257
|
+
}
|
|
2258
|
+
}
|
|
2058
2259
|
if (syncState?.entities?.blobBuckets?.[key]) {
|
|
2059
2260
|
syncState.entities.blobBuckets[key].contentHash = computeFileHash(filePath);
|
|
2060
2261
|
}
|
|
@@ -2064,8 +2265,11 @@ Directory Structure:
|
|
|
2064
2265
|
bucketKey: key,
|
|
2065
2266
|
name: bucket.name || key,
|
|
2066
2267
|
ttlTier: bucket.ttlTier,
|
|
2067
|
-
accessPolicy: bucket.accessPolicy,
|
|
2068
2268
|
};
|
|
2269
|
+
if (bucket.preset)
|
|
2270
|
+
payload.preset = bucket.preset;
|
|
2271
|
+
else if (bucket.accessPolicy)
|
|
2272
|
+
payload.accessPolicy = bucket.accessPolicy;
|
|
2069
2273
|
if (bucket.description)
|
|
2070
2274
|
payload.description = bucket.description;
|
|
2071
2275
|
if (bucket.ruleSetId)
|
|
@@ -2358,7 +2562,10 @@ Directory Structure:
|
|
|
2358
2562
|
changes.push({ type: "script", action: "update", key: name });
|
|
2359
2563
|
if (!options.dryRun) {
|
|
2360
2564
|
try {
|
|
2361
|
-
|
|
2565
|
+
// #1000 — push a new ScriptConfig + activate it. Referencing
|
|
2566
|
+
// workflows pick up the new body on their next run (no
|
|
2567
|
+
// fan-out, no re-push needed).
|
|
2568
|
+
await client.pushScriptBody(resolvedAppId, existingId, body);
|
|
2362
2569
|
info(` Updated script: ${name}`);
|
|
2363
2570
|
if (syncState) {
|
|
2364
2571
|
if (!syncState.entities.scripts) {
|
|
@@ -2366,8 +2573,7 @@ Directory Structure:
|
|
|
2366
2573
|
}
|
|
2367
2574
|
syncState.entities.scripts[name] = {
|
|
2368
2575
|
id: existingId,
|
|
2369
|
-
modifiedAt:
|
|
2370
|
-
syncState.entities.scripts[name]?.modifiedAt ||
|
|
2576
|
+
modifiedAt: syncState.entities.scripts[name]?.modifiedAt ||
|
|
2371
2577
|
new Date().toISOString(),
|
|
2372
2578
|
contentHash: computeFileHash(filePath),
|
|
2373
2579
|
};
|
|
@@ -2389,7 +2595,7 @@ Directory Structure:
|
|
|
2389
2595
|
changes.push({ type: "script", action: "update", key: name });
|
|
2390
2596
|
if (!options.dryRun) {
|
|
2391
2597
|
try {
|
|
2392
|
-
|
|
2598
|
+
await client.pushScriptBody(resolvedAppId, match.scriptId, body);
|
|
2393
2599
|
info(` Updated script: ${name} (adopted from server)`);
|
|
2394
2600
|
if (syncState) {
|
|
2395
2601
|
if (!syncState.entities.scripts) {
|
|
@@ -2397,7 +2603,7 @@ Directory Structure:
|
|
|
2397
2603
|
}
|
|
2398
2604
|
syncState.entities.scripts[name] = {
|
|
2399
2605
|
id: match.scriptId,
|
|
2400
|
-
modifiedAt:
|
|
2606
|
+
modifiedAt: new Date().toISOString(),
|
|
2401
2607
|
contentHash: computeFileHash(filePath),
|
|
2402
2608
|
};
|
|
2403
2609
|
}
|
|
@@ -2445,10 +2651,14 @@ Directory Structure:
|
|
|
2445
2651
|
// place to catch the footgun. Validation happens BEFORE the
|
|
2446
2652
|
// skip-if-unchanged check so a previously-pushed-but-broken
|
|
2447
2653
|
// file gets a clear diagnostic on every push attempt.
|
|
2654
|
+
// Issue #976: validation is now front-loaded before any apply
|
|
2655
|
+
// (see the pre-flight pass above). This in-loop check remains as
|
|
2656
|
+
// defense-in-depth, but THROWS rather than `process.exit(1)` so the
|
|
2657
|
+
// single save-on-failure catch is the only exit path — never
|
|
2658
|
+
// bypassing it and orphaning an already-created resource.
|
|
2448
2659
|
const tomlErrors = validateWorkflowToml(tomlData);
|
|
2449
2660
|
if (tomlErrors.length > 0) {
|
|
2450
|
-
|
|
2451
|
-
process.exit(1);
|
|
2661
|
+
throw new Error(formatWorkflowTomlErrors(filePath, tomlErrors));
|
|
2452
2662
|
}
|
|
2453
2663
|
const workflow = tomlData.workflow || {};
|
|
2454
2664
|
const key = workflow.key || basename(file, ".toml");
|
|
@@ -2501,6 +2711,10 @@ Directory Structure:
|
|
|
2501
2711
|
description: workflow.description,
|
|
2502
2712
|
status: workflow.status,
|
|
2503
2713
|
accessRule: workflow.accessRule !== undefined ? (workflow.accessRule || null) : undefined,
|
|
2714
|
+
// #1081 — workflow principal mode. Absent key → undefined
|
|
2715
|
+
// (server leaves it untouched); explicit value/empty is
|
|
2716
|
+
// forwarded so it round-trips.
|
|
2717
|
+
runAs: workflow.runAs !== undefined ? (workflow.runAs || null) : undefined,
|
|
2504
2718
|
perUserMaxRunning: workflow.perUserMaxRunning,
|
|
2505
2719
|
perUserMaxQueued: workflow.perUserMaxQueued,
|
|
2506
2720
|
dequeueOrder: workflow.dequeueOrder,
|
|
@@ -2595,6 +2809,8 @@ Directory Structure:
|
|
|
2595
2809
|
inputSchema: workflow.inputSchema ? safeJsonParse(workflow.inputSchema) : undefined,
|
|
2596
2810
|
outputSchema: workflow.outputSchema ? safeJsonParse(workflow.outputSchema) : undefined,
|
|
2597
2811
|
accessRule: workflow.accessRule || undefined,
|
|
2812
|
+
// #1081 — workflow principal mode (absent → server default).
|
|
2813
|
+
runAs: workflow.runAs || undefined,
|
|
2598
2814
|
perUserMaxRunning: workflow.perUserMaxRunning,
|
|
2599
2815
|
perUserMaxQueued: workflow.perUserMaxQueued,
|
|
2600
2816
|
dequeueOrder: workflow.dequeueOrder,
|
|
@@ -2679,9 +2895,10 @@ Directory Structure:
|
|
|
2679
2895
|
// (issue #803). Surface it with the file name and abort the
|
|
2680
2896
|
// push rather than crashing with an opaque stack.
|
|
2681
2897
|
if (err instanceof SubscriptionAccessKeyConflictError) {
|
|
2682
|
-
|
|
2683
|
-
|
|
2684
|
-
|
|
2898
|
+
// Issue #976: throw (not `process.exit`) so the save-on-failure
|
|
2899
|
+
// catch is the only exit path. Validation is also front-loaded
|
|
2900
|
+
// above, so this is defense-in-depth.
|
|
2901
|
+
throw new Error(` ${file}: ${err.message}\nAborting push: subscription \`access\`/\`accessRule\` conflict in ${file}.`);
|
|
2685
2902
|
}
|
|
2686
2903
|
throw err;
|
|
2687
2904
|
}
|
|
@@ -2701,13 +2918,22 @@ Directory Structure:
|
|
|
2701
2918
|
warn(` ${formatIssue(w)}`);
|
|
2702
2919
|
}
|
|
2703
2920
|
if (validation.errors.length > 0) {
|
|
2704
|
-
|
|
2705
|
-
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
|
|
2921
|
+
// Issue #976: throw (not `process.exit`) so the save-on-failure
|
|
2922
|
+
// catch is the only exit path. Validation is also front-loaded
|
|
2923
|
+
// above, so this is defense-in-depth.
|
|
2924
|
+
throw new Error(validation.errors.map((e) => ` ${formatIssue(e)}`).join("\n") +
|
|
2925
|
+
`\nAborting push: ${validation.errors.length} unresolved $params reference(s) in ${file}.`);
|
|
2709
2926
|
}
|
|
2710
2927
|
const existingEntry = syncState?.entities?.databaseTypes?.[dbType];
|
|
2928
|
+
// Issue #915 (defect b): track whether the apply path rejected any
|
|
2929
|
+
// op for THIS file via the schema gate (OperationRefError /
|
|
2930
|
+
// SchemaRequiredError). On a non-transactional fresh-type push the
|
|
2931
|
+
// db-type lands but the bad op is blocked and the loop continues; if
|
|
2932
|
+
// we then wrote the file-level contentHash, the next push's
|
|
2933
|
+
// `shouldPushFile` would short-circuit and the missing op would be
|
|
2934
|
+
// invisible drift. When this flag is set we skip the contentHash
|
|
2935
|
+
// write so the drift stays visible and a corrected re-push converges.
|
|
2936
|
+
let fileHadGateRejectedOp = false;
|
|
2711
2937
|
// Skip if file hasn't changed since last sync
|
|
2712
2938
|
if (!options.force && existingEntry && !shouldPushFile(filePath, existingEntry.contentHash)) {
|
|
2713
2939
|
skippedCount++;
|
|
@@ -2794,8 +3020,7 @@ Directory Structure:
|
|
|
2794
3020
|
}
|
|
2795
3021
|
// 2. Op-edit gate (op create/update dry-run) for every op in the
|
|
2796
3022
|
// TOML. The server runs `runSchemaGate` and short-circuits
|
|
2797
|
-
// before persisting.
|
|
2798
|
-
// server yet → gate is a no-op anyway).
|
|
3023
|
+
// before persisting.
|
|
2799
3024
|
//
|
|
2800
3025
|
// The op gate is run against the schema THIS push is landing
|
|
2801
3026
|
// (`schemaOverride`), not the stale stored schema — the real
|
|
@@ -2805,11 +3030,36 @@ Directory Structure:
|
|
|
2805
3030
|
// a schema landed earlier in the same push"). When the push
|
|
2806
3031
|
// isn't changing the schema, leave the override unset so the
|
|
2807
3032
|
// server uses the stored schema.
|
|
2808
|
-
|
|
2809
|
-
|
|
2810
|
-
|
|
2811
|
-
|
|
2812
|
-
|
|
3033
|
+
//
|
|
3034
|
+
// Issue #915 (defect a): this gate now runs for FRESH types too
|
|
3035
|
+
// (previously guarded by `if (existingEntry)`). For a brand-new
|
|
3036
|
+
// db-type the server has no stored config, so we gate the op
|
|
3037
|
+
// against the PROPOSED local schema via `schemaOverride`. The
|
|
3038
|
+
// server's op-create dry-run accepts a missing type when a
|
|
3039
|
+
// `schemaOverride` is present (see
|
|
3040
|
+
// database-type-operations-controller.ts). A fresh schemaless
|
|
3041
|
+
// type (#666 bootstrap) sends an empty/absent override, and
|
|
3042
|
+
// `runSchemaGate` is a no-op without a schema — so that path
|
|
3043
|
+
// still succeeds.
|
|
3044
|
+
{
|
|
3045
|
+
const localHasSchema = typeof typeConfig.schema === "string" &&
|
|
3046
|
+
typeConfig.schema.trim().length > 0;
|
|
3047
|
+
let proposedSchema;
|
|
3048
|
+
if (existingEntry) {
|
|
3049
|
+
const validateUpdateData = computeTypeUpdateData();
|
|
3050
|
+
proposedSchema =
|
|
3051
|
+
"schema" in validateUpdateData
|
|
3052
|
+
? validateUpdateData.schema
|
|
3053
|
+
: undefined;
|
|
3054
|
+
}
|
|
3055
|
+
else {
|
|
3056
|
+
// Fresh type: the proposed schema is whatever the local TOML
|
|
3057
|
+
// declares. A schemaless fresh type (no `[models.*]`) sends an
|
|
3058
|
+
// empty-string override so the server takes the no-schema =
|
|
3059
|
+
// no-op gate path rather than falling back to a (nonexistent)
|
|
3060
|
+
// stored config — keeping the #666 bootstrap convergent.
|
|
3061
|
+
proposedSchema = localHasSchema ? typeConfig.schema : "";
|
|
3062
|
+
}
|
|
2813
3063
|
for (const op of operations) {
|
|
2814
3064
|
const existingOp = existingOpsForType[op.name];
|
|
2815
3065
|
opGateContext = {
|
|
@@ -2833,7 +3083,20 @@ Directory Structure:
|
|
|
2833
3083
|
access: op.access,
|
|
2834
3084
|
definition: op.definition,
|
|
2835
3085
|
params: op.params,
|
|
2836
|
-
}, {
|
|
3086
|
+
}, {
|
|
3087
|
+
dryRun: true,
|
|
3088
|
+
schemaOverride: proposedSchema,
|
|
3089
|
+
// Issue #915 (defect a, follow-up): for a fresh type the
|
|
3090
|
+
// server has no stored config to read `defaultAccess`
|
|
3091
|
+
// from, so an op that omits `access` to inherit the
|
|
3092
|
+
// TOML-declared type-level `defaultAccess` would be
|
|
3093
|
+
// falsely rejected. Thread the proposed value so the
|
|
3094
|
+
// fresh-type dry-run gates the same way the real push
|
|
3095
|
+
// (which lands the type first) will.
|
|
3096
|
+
defaultAccess: typeof typeConfig.defaultAccess === "string"
|
|
3097
|
+
? typeConfig.defaultAccess
|
|
3098
|
+
: undefined,
|
|
3099
|
+
});
|
|
2837
3100
|
}
|
|
2838
3101
|
}
|
|
2839
3102
|
// Every op gate passed; clear the context so a later (non-op)
|
|
@@ -3016,6 +3279,7 @@ Directory Structure:
|
|
|
3016
3279
|
acceptWarnings: !!options.acceptWarnings,
|
|
3017
3280
|
});
|
|
3018
3281
|
info(` Updated database type: ${dbType}`);
|
|
3282
|
+
printStaleInstanceWarning(updated);
|
|
3019
3283
|
if (syncState?.entities?.databaseTypes?.[dbType] && updated?.modifiedAt) {
|
|
3020
3284
|
syncState.entities.databaseTypes[dbType].modifiedAt = updated.modifiedAt;
|
|
3021
3285
|
syncState.entities.databaseTypes[dbType].contentHash = computeFileHash(filePath);
|
|
@@ -3258,6 +3522,7 @@ Directory Structure:
|
|
|
3258
3522
|
dryRun: false,
|
|
3259
3523
|
acceptWarnings: !!options.acceptWarnings,
|
|
3260
3524
|
});
|
|
3525
|
+
printStaleInstanceWarning(reconciled);
|
|
3261
3526
|
if (syncState?.entities?.databaseTypes?.[dbType] &&
|
|
3262
3527
|
reconciled?.modifiedAt) {
|
|
3263
3528
|
syncState.entities.databaseTypes[dbType].modifiedAt =
|
|
@@ -3323,6 +3588,10 @@ Directory Structure:
|
|
|
3323
3588
|
key: `${dbType}/${op.name}`,
|
|
3324
3589
|
message: err.message,
|
|
3325
3590
|
});
|
|
3591
|
+
// Issue #915 (defect b): a gate-rejected op means this
|
|
3592
|
+
// file's state did NOT fully land — don't poison the
|
|
3593
|
+
// content hash (see the contentHash write below).
|
|
3594
|
+
fileHadGateRejectedOp = true;
|
|
3326
3595
|
}
|
|
3327
3596
|
else if (err instanceof OperationRefError) {
|
|
3328
3597
|
schemaErrors.opRefs.push({
|
|
@@ -3331,6 +3600,7 @@ Directory Structure:
|
|
|
3331
3600
|
refs: err.refs,
|
|
3332
3601
|
message: err.message,
|
|
3333
3602
|
});
|
|
3603
|
+
fileHadGateRejectedOp = true;
|
|
3334
3604
|
}
|
|
3335
3605
|
else {
|
|
3336
3606
|
throw wrapEntityError(err, "update", "operation", `${dbType}/${op.name}`);
|
|
@@ -3368,6 +3638,10 @@ Directory Structure:
|
|
|
3368
3638
|
key: `${dbType}/${op.name}`,
|
|
3369
3639
|
message: err.message,
|
|
3370
3640
|
});
|
|
3641
|
+
// Issue #915 (defect b): a gate-rejected op means this
|
|
3642
|
+
// file's state did NOT fully land — don't poison the
|
|
3643
|
+
// content hash (see the contentHash write below).
|
|
3644
|
+
fileHadGateRejectedOp = true;
|
|
3371
3645
|
}
|
|
3372
3646
|
else if (err instanceof OperationRefError) {
|
|
3373
3647
|
schemaErrors.opRefs.push({
|
|
@@ -3376,6 +3650,7 @@ Directory Structure:
|
|
|
3376
3650
|
refs: err.refs,
|
|
3377
3651
|
message: err.message,
|
|
3378
3652
|
});
|
|
3653
|
+
fileHadGateRejectedOp = true;
|
|
3379
3654
|
}
|
|
3380
3655
|
else {
|
|
3381
3656
|
throw wrapEntityError(err, "create", "operation", `${dbType}/${op.name}`);
|
|
@@ -3507,9 +3782,27 @@ Directory Structure:
|
|
|
3507
3782
|
// file. Recompute here so a truly-unchanged subsequent push skips
|
|
3508
3783
|
// the whole file via `shouldPushFile`. `--force` bypasses that
|
|
3509
3784
|
// skip, so forced re-pushes are unaffected.
|
|
3785
|
+
//
|
|
3786
|
+
// Issue #915 (defect b): when the gate rejected an op for this
|
|
3787
|
+
// file, do NOT record a matching content hash. The db-type itself
|
|
3788
|
+
// may have been created (the push is non-transactional), but the
|
|
3789
|
+
// blocked op never landed — a matching hash would make
|
|
3790
|
+
// `shouldPushFile` short-circuit the next push and hide the missing
|
|
3791
|
+
// op as "in sync". Clear the hash instead of writing it: an empty
|
|
3792
|
+
// hash forces `shouldPushFile` to re-evaluate the file, so a
|
|
3793
|
+
// subsequent `sync push --dry-run` still surfaces the op as drift
|
|
3794
|
+
// and a corrected re-push converges without manual file edits. We
|
|
3795
|
+
// clear (rather than merely skip) so the fresh-type create path's
|
|
3796
|
+
// earlier optimistic hash write (see `createDatabaseTypeConfig`
|
|
3797
|
+
// above) is also invalidated.
|
|
3510
3798
|
if (!options.dryRun && syncState?.entities?.databaseTypes?.[dbType]) {
|
|
3511
|
-
|
|
3512
|
-
|
|
3799
|
+
if (fileHadGateRejectedOp) {
|
|
3800
|
+
syncState.entities.databaseTypes[dbType].contentHash = "";
|
|
3801
|
+
}
|
|
3802
|
+
else {
|
|
3803
|
+
syncState.entities.databaseTypes[dbType].contentHash =
|
|
3804
|
+
computeFileHash(filePath);
|
|
3805
|
+
}
|
|
3513
3806
|
}
|
|
3514
3807
|
}
|
|
3515
3808
|
}
|
|
@@ -4264,5 +4557,118 @@ Directory Structure:
|
|
|
4264
4557
|
success(`Rewrote ${migratedCount} TOML file(s) to native form.`);
|
|
4265
4558
|
}
|
|
4266
4559
|
});
|
|
4560
|
+
// Revert — restore a pre-pull snapshot (issue #578, Phase 1).
|
|
4561
|
+
sync
|
|
4562
|
+
.command("revert")
|
|
4563
|
+
.description("Restore the sync directory from a snapshot taken before a previous pull")
|
|
4564
|
+
.argument("[app-id]", "App ID (uses current app if not specified)")
|
|
4565
|
+
.option("--app <app-id>", "App ID")
|
|
4566
|
+
.option("--dir <path>", "Config directory (overrides the auto-resolved per-env path)")
|
|
4567
|
+
.option("--snapshot <id>", "Snapshot id (timestamp dirname or a unique >=8-char prefix)")
|
|
4568
|
+
.option("--list", "List available snapshots without restoring")
|
|
4569
|
+
.option("-y, --yes", "Skip the confirmation prompt")
|
|
4570
|
+
.action(async (appId, options) => {
|
|
4571
|
+
const resolvedAppId = resolveAppId(appId, options);
|
|
4572
|
+
const configDir = resolveSyncDir({
|
|
4573
|
+
appId: resolvedAppId,
|
|
4574
|
+
userDir: options.dir,
|
|
4575
|
+
});
|
|
4576
|
+
const snapshotsRoot = resolveSnapshotsRoot({
|
|
4577
|
+
appId: resolvedAppId,
|
|
4578
|
+
userDir: options.dir,
|
|
4579
|
+
});
|
|
4580
|
+
if (isAutoResolvedSyncDir(options.dir)) {
|
|
4581
|
+
info(`Using per-environment sync directory: ${configDir}`);
|
|
4582
|
+
}
|
|
4583
|
+
const snapshots = listSnapshots(snapshotsRoot);
|
|
4584
|
+
// --list, or no snapshots at all: enumerate and stop.
|
|
4585
|
+
if (options.list || snapshots.length === 0) {
|
|
4586
|
+
if (snapshots.length === 0) {
|
|
4587
|
+
info(`No snapshots found in ${snapshotsRoot}`);
|
|
4588
|
+
info("Snapshots are created automatically before each 'sync pull'.");
|
|
4589
|
+
return;
|
|
4590
|
+
}
|
|
4591
|
+
divider();
|
|
4592
|
+
info(`Snapshots for this slot (${snapshotsRoot}), newest first:`);
|
|
4593
|
+
for (const snap of snapshots) {
|
|
4594
|
+
const flags = [];
|
|
4595
|
+
if (!snap.complete)
|
|
4596
|
+
flags.push("INCOMPLETE");
|
|
4597
|
+
if (snap.auditId)
|
|
4598
|
+
flags.push(`audit=${snap.auditId}`);
|
|
4599
|
+
const suffix = flags.length ? ` (${flags.join(", ")})` : "";
|
|
4600
|
+
keyValue(snap.id, `${snap.createdAt.toISOString()}${suffix}`);
|
|
4601
|
+
}
|
|
4602
|
+
if (!options.list) {
|
|
4603
|
+
info("");
|
|
4604
|
+
info("Run 'primitive sync revert --snapshot <id>' to restore one, " +
|
|
4605
|
+
"or 'primitive sync revert' to restore the most recent.");
|
|
4606
|
+
}
|
|
4607
|
+
return;
|
|
4608
|
+
}
|
|
4609
|
+
// Resolve the target snapshot (exact id, unique prefix, or most-recent).
|
|
4610
|
+
let target;
|
|
4611
|
+
try {
|
|
4612
|
+
target = resolveSnapshot(snapshotsRoot, options.snapshot);
|
|
4613
|
+
}
|
|
4614
|
+
catch (err) {
|
|
4615
|
+
error(err?.message ?? String(err));
|
|
4616
|
+
process.exit(1);
|
|
4617
|
+
}
|
|
4618
|
+
if (!target.complete) {
|
|
4619
|
+
error(`Snapshot ${target.id} is incomplete (missing the integrity marker) and cannot be restored safely.`);
|
|
4620
|
+
process.exit(1);
|
|
4621
|
+
}
|
|
4622
|
+
// Confirmation, with a dirty-git warning.
|
|
4623
|
+
if (!options.yes) {
|
|
4624
|
+
if (await hasUncommittedChanges(configDir)) {
|
|
4625
|
+
warn(`You have uncommitted git changes under ${configDir}. ` +
|
|
4626
|
+
"Reverting will overwrite them.");
|
|
4627
|
+
}
|
|
4628
|
+
let confirm;
|
|
4629
|
+
try {
|
|
4630
|
+
confirm = await confirmPrompt(`Restore snapshot ${target.id} into ${configDir}? This overwrites the current sync directory.`);
|
|
4631
|
+
}
|
|
4632
|
+
catch (err) {
|
|
4633
|
+
error(err.message);
|
|
4634
|
+
process.exit(1);
|
|
4635
|
+
}
|
|
4636
|
+
if (!confirm) {
|
|
4637
|
+
info("Cancelled.");
|
|
4638
|
+
return;
|
|
4639
|
+
}
|
|
4640
|
+
}
|
|
4641
|
+
try {
|
|
4642
|
+
// Under legacy `--dir`, snapshotsRoot lives inside configDir; preserve
|
|
4643
|
+
// it across the full-tree swap so we don't wipe backup history.
|
|
4644
|
+
restoreSnapshot(target.path, configDir, { preserveDir: snapshotsRoot });
|
|
4645
|
+
}
|
|
4646
|
+
catch (err) {
|
|
4647
|
+
error(`Restore failed: ${err?.message ?? err}`);
|
|
4648
|
+
process.exit(1);
|
|
4649
|
+
}
|
|
4650
|
+
success(`Restored snapshot ${target.id} into ${configDir} (including .primitive-sync.json).`);
|
|
4651
|
+
info("Run 'primitive sync diff' to inspect the restored state versus the server.");
|
|
4652
|
+
});
|
|
4653
|
+
}
|
|
4654
|
+
/**
|
|
4655
|
+
* Best-effort check for uncommitted git changes under `dir`. Used only to warn
|
|
4656
|
+
* before a revert overwrites local edits — never fatal. Returns false if git
|
|
4657
|
+
* isn't available, the dir isn't in a repo, or anything goes wrong.
|
|
4658
|
+
*/
|
|
4659
|
+
async function hasUncommittedChanges(dir) {
|
|
4660
|
+
if (!existsSync(dir))
|
|
4661
|
+
return false;
|
|
4662
|
+
try {
|
|
4663
|
+
const { execSync } = await import("child_process");
|
|
4664
|
+
const out = execSync(`git status --porcelain -- "${dir}"`, {
|
|
4665
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
4666
|
+
encoding: "utf-8",
|
|
4667
|
+
});
|
|
4668
|
+
return out.trim().length > 0;
|
|
4669
|
+
}
|
|
4670
|
+
catch {
|
|
4671
|
+
return false;
|
|
4672
|
+
}
|
|
4267
4673
|
}
|
|
4268
4674
|
//# sourceMappingURL=sync.js.map
|