primitive-admin 1.0.48 → 1.0.50
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 +102 -2
- 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/analytics.d.ts +2 -0
- package/dist/src/commands/apps.d.ts +2 -0
- package/dist/src/commands/apps.js +20 -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/catalog.d.ts +2 -0
- package/dist/src/commands/collection-type-configs.d.ts +2 -0
- package/dist/src/commands/collections.d.ts +2 -0
- 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/databases.d.ts +2 -0
- package/dist/src/commands/databases.js +31 -0
- package/dist/src/commands/databases.js.map +1 -1
- package/dist/src/commands/documents.d.ts +2 -0
- package/dist/src/commands/email-templates.d.ts +2 -0
- package/dist/src/commands/env.d.ts +12 -0
- package/dist/src/commands/group-type-configs.d.ts +2 -0
- package/dist/src/commands/groups.d.ts +2 -0
- 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 +22 -5
- package/dist/src/commands/integrations.js.map +1 -1
- package/dist/src/commands/llm.d.ts +2 -0
- package/dist/src/commands/prompts.d.ts +2 -0
- package/dist/src/commands/rule-sets.d.ts +2 -0
- 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 +113 -0
- package/dist/src/commands/sync.js +366 -12
- package/dist/src/commands/sync.js.map +1 -1
- package/dist/src/commands/tokens.d.ts +2 -0
- package/dist/src/commands/tokens.js +104 -1
- package/dist/src/commands/tokens.js.map +1 -1
- package/dist/src/commands/users.d.ts +2 -0
- package/dist/src/commands/waitlist.d.ts +2 -0
- package/dist/src/commands/waitlist.js +1 -1
- package/dist/src/commands/waitlist.js.map +1 -1
- package/dist/src/commands/webhooks.d.ts +2 -0
- package/dist/src/commands/workflows.d.ts +49 -0
- package/dist/src/commands/workflows.js +74 -21
- package/dist/src/commands/workflows.js.map +1 -1
- package/dist/src/lib/api-client.d.ts +1244 -0
- package/dist/src/lib/api-client.js +30 -0
- 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 +66 -0
- package/dist/src/lib/confirm-prompt.js +85 -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/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 +86 -0
- package/dist/src/lib/workflow-toml-validator.js +31 -1
- 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 +8 -2
|
@@ -7,7 +7,8 @@ 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";
|
|
@@ -17,6 +18,19 @@ function ensureDir(dirPath) {
|
|
|
17
18
|
mkdirSync(dirPath, { recursive: true });
|
|
18
19
|
}
|
|
19
20
|
}
|
|
21
|
+
/**
|
|
22
|
+
* Issue #974: when a type-config push newly declares fields `unique = true`
|
|
23
|
+
* and the type already has instances, the server returns a
|
|
24
|
+
* `staleInstanceWarning`. Surface it so the operator knows the existing
|
|
25
|
+
* instances still lack the index and how to back-provision them. The push
|
|
26
|
+
* itself still succeeded — this is informational, not a failure.
|
|
27
|
+
*/
|
|
28
|
+
function printStaleInstanceWarning(updated) {
|
|
29
|
+
const w = updated?.staleInstanceWarning;
|
|
30
|
+
if (w && typeof w.message === "string") {
|
|
31
|
+
warn(` ${w.message}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
20
34
|
function loadSyncState(configDir) {
|
|
21
35
|
const syncFile = join(configDir, ".primitive-sync.json");
|
|
22
36
|
if (!existsSync(syncFile)) {
|
|
@@ -720,6 +734,56 @@ export async function pullScripts(client, appId, configDir, logger = () => { })
|
|
|
720
734
|
}
|
|
721
735
|
return { scriptEntities, count: items.length };
|
|
722
736
|
}
|
|
737
|
+
/**
|
|
738
|
+
* Issue #973 — format the "stale referencing workflows" warning for a script
|
|
739
|
+
* push. Pure (no I/O) so it can be unit-tested directly.
|
|
740
|
+
*
|
|
741
|
+
* `staleWorkflows` is the server's report of active workflows whose frozen
|
|
742
|
+
* snapshot still pins a DIFFERENT (old) body hash for `scriptName` — i.e. the
|
|
743
|
+
* workflows that will keep running the previous script body until republished.
|
|
744
|
+
* Returns `null` when nothing is stale (no warning to print). When `dryRun` is
|
|
745
|
+
* true the wording reflects that nothing was mutated yet.
|
|
746
|
+
*/
|
|
747
|
+
export function formatStaleWorkflowsWarning(scriptName, staleWorkflows, dryRun = false) {
|
|
748
|
+
if (!staleWorkflows || staleWorkflows.length === 0)
|
|
749
|
+
return null;
|
|
750
|
+
const labels = staleWorkflows.map((w) => w.workflowKey || w.name || "(unknown workflow)");
|
|
751
|
+
const count = labels.length;
|
|
752
|
+
const noun = count === 1 ? "workflow" : "workflows";
|
|
753
|
+
const verb = dryRun ? "would still run" : "still run";
|
|
754
|
+
return (`Script "${scriptName}" ${dryRun ? "would be updated" : "updated"}, but ` +
|
|
755
|
+
`${count} ${noun} ${verb} the previous body until re-pushed: ` +
|
|
756
|
+
`${labels.join(", ")}. ` +
|
|
757
|
+
`Re-push (or republish) ${count === 1 ? "it" : "them"} to pick up the change ` +
|
|
758
|
+
`(the run path reads each workflow's frozen snapshot, not the live script).`);
|
|
759
|
+
}
|
|
760
|
+
/**
|
|
761
|
+
* Issue #973 — query the server for active workflows left stale by a script
|
|
762
|
+
* push and print a warning naming them. Best-effort: a missing route (older
|
|
763
|
+
* server) or any transport error is swallowed so it never fails the sync.
|
|
764
|
+
*
|
|
765
|
+
* `contentHash` is the body hash being pushed (the server's authoritative hash
|
|
766
|
+
* after a real push, or the locally-computed would-be hash for `--dry-run`).
|
|
767
|
+
*/
|
|
768
|
+
async function warnStaleWorkflowsForScript(client, appId, scriptId, contentHash, dryRun) {
|
|
769
|
+
let report;
|
|
770
|
+
try {
|
|
771
|
+
report = await client.getStaleWorkflowsForScript(appId, scriptId, contentHash);
|
|
772
|
+
}
|
|
773
|
+
catch {
|
|
774
|
+
// Older server without the route, or a transient failure — staleness
|
|
775
|
+
// info is advisory, so we stay silent rather than break the push.
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
778
|
+
const message = formatStaleWorkflowsWarning(report.scriptName, report.staleWorkflows, dryRun);
|
|
779
|
+
if (message) {
|
|
780
|
+
warn(` ${message}`);
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
/** SHA-256 hex of a string body, matching the server's script `contentHash`. */
|
|
784
|
+
function sha256HexString(body) {
|
|
785
|
+
return createHash("sha256").update(body, "utf-8").digest("hex");
|
|
786
|
+
}
|
|
723
787
|
async function pullTestCasesForBlock(client, appId, blockType, blockId, blockKey, configDir, testCaseEntities, lookupMaps) {
|
|
724
788
|
let testCases;
|
|
725
789
|
try {
|
|
@@ -1153,6 +1217,35 @@ Directory Structure:
|
|
|
1153
1217
|
subscriptions: Array.isArray(subs) ? subs : [],
|
|
1154
1218
|
};
|
|
1155
1219
|
}));
|
|
1220
|
+
// Snapshot the pre-pull sync tree BEFORE we touch any local file
|
|
1221
|
+
// (issue #578, Phase 1). All server data has been fetched and
|
|
1222
|
+
// validated at this point — a failed pull already threw above, so we
|
|
1223
|
+
// never create empty snapshots — but no local file has been
|
|
1224
|
+
// overwritten yet. This is the proven copy-point: between the last
|
|
1225
|
+
// server fetch and the first directory mutation.
|
|
1226
|
+
//
|
|
1227
|
+
// Fail LOUD: if the snapshot can't be written, abort the pull BEFORE
|
|
1228
|
+
// the ensureDir below. Better to refuse a destructive pull than
|
|
1229
|
+
// perform it without a recoverable backup. No-op on a fresh/empty
|
|
1230
|
+
// sync dir (nothing to back up). Prune to 28 days after success.
|
|
1231
|
+
try {
|
|
1232
|
+
const snapshotsRoot = resolveSnapshotsRoot({
|
|
1233
|
+
appId: resolvedAppId,
|
|
1234
|
+
userDir: options.dir,
|
|
1235
|
+
});
|
|
1236
|
+
const snapshot = createSnapshot(configDir, snapshotsRoot);
|
|
1237
|
+
if (snapshot) {
|
|
1238
|
+
info(` Snapshot: ${snapshot.path}`);
|
|
1239
|
+
pruneSnapshots(snapshotsRoot, 28);
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
catch (snapErr) {
|
|
1243
|
+
error(`Failed to create a pre-pull snapshot: ${snapErr?.message ?? snapErr}`);
|
|
1244
|
+
error("Aborting pull before any local file is modified. " +
|
|
1245
|
+
"Resolve the snapshot error (e.g. disk space / permissions) and retry, " +
|
|
1246
|
+
"or pass a writable --dir.");
|
|
1247
|
+
process.exit(1);
|
|
1248
|
+
}
|
|
1156
1249
|
// Ensure directories exist
|
|
1157
1250
|
ensureDir(configDir);
|
|
1158
1251
|
ensureDir(join(configDir, "integrations"));
|
|
@@ -1660,6 +1753,82 @@ Directory Structure:
|
|
|
1660
1753
|
const promptKeyToId = new Map();
|
|
1661
1754
|
const promptConfigNameToId = new Map(); // key: "promptKey#configName"
|
|
1662
1755
|
const workflowConfigNameToId = new Map(); // key: "workflowKey#configName"
|
|
1756
|
+
// ── Issue #976 (fix A): front-load ALL client-side TOML validation
|
|
1757
|
+
// before the first mutating call. Push is apply-as-you-go and processes
|
|
1758
|
+
// entities in a fixed sequence (cron triggers are created *before*
|
|
1759
|
+
// workflows are validated). Previously each entity's validation lived
|
|
1760
|
+
// inside its own apply loop and aborted via `process.exit(1)` — so a
|
|
1761
|
+
// known-bad sibling file (e.g. an invalid workflow TOML) could let an
|
|
1762
|
+
// earlier create (a cron trigger) be applied, then abort. The
|
|
1763
|
+
// `process.exit` also bypassed the save-on-failure catch below, so the
|
|
1764
|
+
// orphaned create never reached `.primitive-sync.json` and the retry
|
|
1765
|
+
// re-issued a CREATE that the server rejected with 409 forever.
|
|
1766
|
+
//
|
|
1767
|
+
// Running every validation up-front means a known-bad file aborts
|
|
1768
|
+
// BEFORE anything is created. We collect all errors (not just the first)
|
|
1769
|
+
// and THROW — so the single save-on-failure catch is the only exit path
|
|
1770
|
+
// (no more `process.exit` bypass class). The per-loop validators below
|
|
1771
|
+
// remain as defense-in-depth but are converted to throws too.
|
|
1772
|
+
const preflightValidationErrors = [];
|
|
1773
|
+
// Validate all workflow TOMLs (issue #685 misnested-header check).
|
|
1774
|
+
const preflightWorkflowsDir = join(configDir, "workflows");
|
|
1775
|
+
if (existsSync(preflightWorkflowsDir)) {
|
|
1776
|
+
for (const file of readdirSync(preflightWorkflowsDir).filter((f) => f.endsWith(".toml"))) {
|
|
1777
|
+
const filePath = join(preflightWorkflowsDir, file);
|
|
1778
|
+
try {
|
|
1779
|
+
const tomlData = parseTomlFile(filePath);
|
|
1780
|
+
const tomlErrors = validateWorkflowToml(tomlData);
|
|
1781
|
+
if (tomlErrors.length > 0) {
|
|
1782
|
+
preflightValidationErrors.push(formatWorkflowTomlErrors(filePath, tomlErrors));
|
|
1783
|
+
}
|
|
1784
|
+
}
|
|
1785
|
+
catch (err) {
|
|
1786
|
+
preflightValidationErrors.push(` ${file}: ${err?.message || String(err)}`);
|
|
1787
|
+
}
|
|
1788
|
+
}
|
|
1789
|
+
}
|
|
1790
|
+
// Validate all database-type TOMLs (issue #803 subscription
|
|
1791
|
+
// access/accessRule conflict + issue #752 $params references).
|
|
1792
|
+
const preflightDbTypesDir = join(configDir, "database-types");
|
|
1793
|
+
if (existsSync(preflightDbTypesDir)) {
|
|
1794
|
+
for (const file of readdirSync(preflightDbTypesDir).filter((f) => f.endsWith(".toml"))) {
|
|
1795
|
+
const filePath = join(preflightDbTypesDir, file);
|
|
1796
|
+
let rawToml;
|
|
1797
|
+
let tomlData;
|
|
1798
|
+
try {
|
|
1799
|
+
rawToml = readFileSync(filePath, "utf-8");
|
|
1800
|
+
tomlData = parseTomlFile(filePath);
|
|
1801
|
+
}
|
|
1802
|
+
catch (err) {
|
|
1803
|
+
preflightValidationErrors.push(` ${file}: ${err?.message || String(err)}`);
|
|
1804
|
+
continue;
|
|
1805
|
+
}
|
|
1806
|
+
let operations = [];
|
|
1807
|
+
try {
|
|
1808
|
+
({ operations } = parseDatabaseTypeToml(tomlData));
|
|
1809
|
+
}
|
|
1810
|
+
catch (err) {
|
|
1811
|
+
if (err instanceof SubscriptionAccessKeyConflictError) {
|
|
1812
|
+
preflightValidationErrors.push(` ${file}: ${err.message}`);
|
|
1813
|
+
continue;
|
|
1814
|
+
}
|
|
1815
|
+
preflightValidationErrors.push(` ${file}: ${err?.message || String(err)}`);
|
|
1816
|
+
continue;
|
|
1817
|
+
}
|
|
1818
|
+
const validation = validateOperations({
|
|
1819
|
+
filePath,
|
|
1820
|
+
rawToml,
|
|
1821
|
+
operations,
|
|
1822
|
+
});
|
|
1823
|
+
for (const e of validation.errors) {
|
|
1824
|
+
preflightValidationErrors.push(` ${formatIssue(e)}`);
|
|
1825
|
+
}
|
|
1826
|
+
}
|
|
1827
|
+
}
|
|
1828
|
+
if (preflightValidationErrors.length > 0) {
|
|
1829
|
+
throw new Error(`Aborting push: ${preflightValidationErrors.length} TOML validation error(s) — no changes were applied.\n` +
|
|
1830
|
+
preflightValidationErrors.join("\n"));
|
|
1831
|
+
}
|
|
1663
1832
|
// Process app settings
|
|
1664
1833
|
const appTomlPath = join(configDir, "app.toml");
|
|
1665
1834
|
if (existsSync(appTomlPath)) {
|
|
@@ -2031,7 +2200,58 @@ Directory Structure:
|
|
|
2031
2200
|
}
|
|
2032
2201
|
}
|
|
2033
2202
|
catch (err) {
|
|
2034
|
-
|
|
2203
|
+
// Issue #976 (fix B): idempotent create — adopt-by-key on 409.
|
|
2204
|
+
// A cron trigger can be orphaned on the server (created by a
|
|
2205
|
+
// prior push that aborted before recording it in sync state,
|
|
2206
|
+
// a mid-apply crash, or an out-of-band create with the same
|
|
2207
|
+
// key). On retry this CREATE path then hits the
|
|
2208
|
+
// `triggerKeyPerApp` unique constraint and the server returns
|
|
2209
|
+
// 409 "A cron trigger with this key already exists". Rather
|
|
2210
|
+
// than hard-fail forever, look up the existing trigger by key
|
|
2211
|
+
// (the list endpoint is app-scoped, so every item is owned by
|
|
2212
|
+
// this app), verify the SAME triggerKey, adopt its id into
|
|
2213
|
+
// sync state, and re-issue as an UPDATE so the push converges.
|
|
2214
|
+
const msg = String(err?.message || err);
|
|
2215
|
+
const is409 = err?.statusCode === 409 || msg.includes("already exists");
|
|
2216
|
+
if (is409) {
|
|
2217
|
+
info(` Cron trigger already exists on server, adopting by key: ${key}`);
|
|
2218
|
+
let adoptedId;
|
|
2219
|
+
try {
|
|
2220
|
+
const { items } = await client.listCronTriggers(resolvedAppId);
|
|
2221
|
+
// Verify same key + app ownership: the list endpoint only
|
|
2222
|
+
// returns triggers for `resolvedAppId`, so a triggerKey
|
|
2223
|
+
// match is also an ownership match. Never overwrite an
|
|
2224
|
+
// unrelated resource — require an exact key equality.
|
|
2225
|
+
const existing = (items || []).find((t) => t?.triggerKey === key);
|
|
2226
|
+
if (existing?.triggerId) {
|
|
2227
|
+
adoptedId = existing.triggerId;
|
|
2228
|
+
}
|
|
2229
|
+
}
|
|
2230
|
+
catch (lookupErr) {
|
|
2231
|
+
throw wrapEntityError(new Error(`cron trigger "${key}" already exists but could not be adopted (lookup failed: ${String(lookupErr?.message || lookupErr)})`), "create", "cron trigger", key);
|
|
2232
|
+
}
|
|
2233
|
+
if (!adoptedId) {
|
|
2234
|
+
// 409 but no matching key found on the server — surface
|
|
2235
|
+
// the original error rather than silently overwriting.
|
|
2236
|
+
throw wrapEntityError(err, "create", "cron trigger", key);
|
|
2237
|
+
}
|
|
2238
|
+
// Switch to UPDATE on the adopted trigger.
|
|
2239
|
+
const updated = await client.updateCronTrigger(resolvedAppId, adoptedId, payload);
|
|
2240
|
+
info(` Adopted + updated cron trigger: ${key}`);
|
|
2241
|
+
if (syncState) {
|
|
2242
|
+
if (!syncState.entities.cronTriggers) {
|
|
2243
|
+
syncState.entities.cronTriggers = {};
|
|
2244
|
+
}
|
|
2245
|
+
syncState.entities.cronTriggers[key] = {
|
|
2246
|
+
id: adoptedId,
|
|
2247
|
+
modifiedAt: updated?.modifiedAt || new Date().toISOString(),
|
|
2248
|
+
contentHash: computeFileHash(filePath),
|
|
2249
|
+
};
|
|
2250
|
+
}
|
|
2251
|
+
}
|
|
2252
|
+
else {
|
|
2253
|
+
throw wrapEntityError(err, "create", "cron trigger", key);
|
|
2254
|
+
}
|
|
2035
2255
|
}
|
|
2036
2256
|
}
|
|
2037
2257
|
}
|
|
@@ -2372,11 +2592,18 @@ Directory Structure:
|
|
|
2372
2592
|
contentHash: computeFileHash(filePath),
|
|
2373
2593
|
};
|
|
2374
2594
|
}
|
|
2595
|
+
// Issue #973 — warn if active workflows still pin the OLD
|
|
2596
|
+
// body. Use the server's authoritative post-push hash.
|
|
2597
|
+
await warnStaleWorkflowsForScript(client, resolvedAppId, existingId, updated?.contentHash || sha256HexString(body), false);
|
|
2375
2598
|
}
|
|
2376
2599
|
catch (err) {
|
|
2377
2600
|
throw wrapEntityError(err, "update", "script", name);
|
|
2378
2601
|
}
|
|
2379
2602
|
}
|
|
2603
|
+
else {
|
|
2604
|
+
// Dry-run: surface the would-be stale set without mutating.
|
|
2605
|
+
await warnStaleWorkflowsForScript(client, resolvedAppId, existingId, sha256HexString(body), true);
|
|
2606
|
+
}
|
|
2380
2607
|
}
|
|
2381
2608
|
else {
|
|
2382
2609
|
// No id in local state — could be brand new OR could be
|
|
@@ -2401,11 +2628,17 @@ Directory Structure:
|
|
|
2401
2628
|
contentHash: computeFileHash(filePath),
|
|
2402
2629
|
};
|
|
2403
2630
|
}
|
|
2631
|
+
// Issue #973 — same staleness warning on the adopt path.
|
|
2632
|
+
await warnStaleWorkflowsForScript(client, resolvedAppId, match.scriptId, updated?.contentHash || sha256HexString(body), false);
|
|
2404
2633
|
}
|
|
2405
2634
|
catch (err) {
|
|
2406
2635
|
throw wrapEntityError(err, "update", "script", name);
|
|
2407
2636
|
}
|
|
2408
2637
|
}
|
|
2638
|
+
else {
|
|
2639
|
+
// Dry-run on the adopt path.
|
|
2640
|
+
await warnStaleWorkflowsForScript(client, resolvedAppId, match.scriptId, sha256HexString(body), true);
|
|
2641
|
+
}
|
|
2409
2642
|
}
|
|
2410
2643
|
else {
|
|
2411
2644
|
changes.push({ type: "script", action: "create", key: name });
|
|
@@ -2445,10 +2678,14 @@ Directory Structure:
|
|
|
2445
2678
|
// place to catch the footgun. Validation happens BEFORE the
|
|
2446
2679
|
// skip-if-unchanged check so a previously-pushed-but-broken
|
|
2447
2680
|
// file gets a clear diagnostic on every push attempt.
|
|
2681
|
+
// Issue #976: validation is now front-loaded before any apply
|
|
2682
|
+
// (see the pre-flight pass above). This in-loop check remains as
|
|
2683
|
+
// defense-in-depth, but THROWS rather than `process.exit(1)` so the
|
|
2684
|
+
// single save-on-failure catch is the only exit path — never
|
|
2685
|
+
// bypassing it and orphaning an already-created resource.
|
|
2448
2686
|
const tomlErrors = validateWorkflowToml(tomlData);
|
|
2449
2687
|
if (tomlErrors.length > 0) {
|
|
2450
|
-
|
|
2451
|
-
process.exit(1);
|
|
2688
|
+
throw new Error(formatWorkflowTomlErrors(filePath, tomlErrors));
|
|
2452
2689
|
}
|
|
2453
2690
|
const workflow = tomlData.workflow || {};
|
|
2454
2691
|
const key = workflow.key || basename(file, ".toml");
|
|
@@ -2679,9 +2916,10 @@ Directory Structure:
|
|
|
2679
2916
|
// (issue #803). Surface it with the file name and abort the
|
|
2680
2917
|
// push rather than crashing with an opaque stack.
|
|
2681
2918
|
if (err instanceof SubscriptionAccessKeyConflictError) {
|
|
2682
|
-
|
|
2683
|
-
|
|
2684
|
-
|
|
2919
|
+
// Issue #976: throw (not `process.exit`) so the save-on-failure
|
|
2920
|
+
// catch is the only exit path. Validation is also front-loaded
|
|
2921
|
+
// above, so this is defense-in-depth.
|
|
2922
|
+
throw new Error(` ${file}: ${err.message}\nAborting push: subscription \`access\`/\`accessRule\` conflict in ${file}.`);
|
|
2685
2923
|
}
|
|
2686
2924
|
throw err;
|
|
2687
2925
|
}
|
|
@@ -2701,11 +2939,11 @@ Directory Structure:
|
|
|
2701
2939
|
warn(` ${formatIssue(w)}`);
|
|
2702
2940
|
}
|
|
2703
2941
|
if (validation.errors.length > 0) {
|
|
2704
|
-
|
|
2705
|
-
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
|
|
2942
|
+
// Issue #976: throw (not `process.exit`) so the save-on-failure
|
|
2943
|
+
// catch is the only exit path. Validation is also front-loaded
|
|
2944
|
+
// above, so this is defense-in-depth.
|
|
2945
|
+
throw new Error(validation.errors.map((e) => ` ${formatIssue(e)}`).join("\n") +
|
|
2946
|
+
`\nAborting push: ${validation.errors.length} unresolved $params reference(s) in ${file}.`);
|
|
2709
2947
|
}
|
|
2710
2948
|
const existingEntry = syncState?.entities?.databaseTypes?.[dbType];
|
|
2711
2949
|
// Skip if file hasn't changed since last sync
|
|
@@ -3016,6 +3254,7 @@ Directory Structure:
|
|
|
3016
3254
|
acceptWarnings: !!options.acceptWarnings,
|
|
3017
3255
|
});
|
|
3018
3256
|
info(` Updated database type: ${dbType}`);
|
|
3257
|
+
printStaleInstanceWarning(updated);
|
|
3019
3258
|
if (syncState?.entities?.databaseTypes?.[dbType] && updated?.modifiedAt) {
|
|
3020
3259
|
syncState.entities.databaseTypes[dbType].modifiedAt = updated.modifiedAt;
|
|
3021
3260
|
syncState.entities.databaseTypes[dbType].contentHash = computeFileHash(filePath);
|
|
@@ -3258,6 +3497,7 @@ Directory Structure:
|
|
|
3258
3497
|
dryRun: false,
|
|
3259
3498
|
acceptWarnings: !!options.acceptWarnings,
|
|
3260
3499
|
});
|
|
3500
|
+
printStaleInstanceWarning(reconciled);
|
|
3261
3501
|
if (syncState?.entities?.databaseTypes?.[dbType] &&
|
|
3262
3502
|
reconciled?.modifiedAt) {
|
|
3263
3503
|
syncState.entities.databaseTypes[dbType].modifiedAt =
|
|
@@ -4264,5 +4504,119 @@ Directory Structure:
|
|
|
4264
4504
|
success(`Rewrote ${migratedCount} TOML file(s) to native form.`);
|
|
4265
4505
|
}
|
|
4266
4506
|
});
|
|
4507
|
+
// Revert — restore a pre-pull snapshot (issue #578, Phase 1).
|
|
4508
|
+
sync
|
|
4509
|
+
.command("revert")
|
|
4510
|
+
.description("Restore the sync directory from a snapshot taken before a previous pull")
|
|
4511
|
+
.argument("[app-id]", "App ID (uses current app if not specified)")
|
|
4512
|
+
.option("--app <app-id>", "App ID")
|
|
4513
|
+
.option("--dir <path>", "Config directory (overrides the auto-resolved per-env path)")
|
|
4514
|
+
.option("--snapshot <id>", "Snapshot id (timestamp dirname or a unique >=8-char prefix)")
|
|
4515
|
+
.option("--list", "List available snapshots without restoring")
|
|
4516
|
+
.option("-y, --yes", "Skip the confirmation prompt")
|
|
4517
|
+
.action(async (appId, options) => {
|
|
4518
|
+
const resolvedAppId = resolveAppId(appId, options);
|
|
4519
|
+
const configDir = resolveSyncDir({
|
|
4520
|
+
appId: resolvedAppId,
|
|
4521
|
+
userDir: options.dir,
|
|
4522
|
+
});
|
|
4523
|
+
const snapshotsRoot = resolveSnapshotsRoot({
|
|
4524
|
+
appId: resolvedAppId,
|
|
4525
|
+
userDir: options.dir,
|
|
4526
|
+
});
|
|
4527
|
+
if (isAutoResolvedSyncDir(options.dir)) {
|
|
4528
|
+
info(`Using per-environment sync directory: ${configDir}`);
|
|
4529
|
+
}
|
|
4530
|
+
const snapshots = listSnapshots(snapshotsRoot);
|
|
4531
|
+
// --list, or no snapshots at all: enumerate and stop.
|
|
4532
|
+
if (options.list || snapshots.length === 0) {
|
|
4533
|
+
if (snapshots.length === 0) {
|
|
4534
|
+
info(`No snapshots found in ${snapshotsRoot}`);
|
|
4535
|
+
info("Snapshots are created automatically before each 'sync pull'.");
|
|
4536
|
+
return;
|
|
4537
|
+
}
|
|
4538
|
+
divider();
|
|
4539
|
+
info(`Snapshots for this slot (${snapshotsRoot}), newest first:`);
|
|
4540
|
+
for (const snap of snapshots) {
|
|
4541
|
+
const flags = [];
|
|
4542
|
+
if (!snap.complete)
|
|
4543
|
+
flags.push("INCOMPLETE");
|
|
4544
|
+
if (snap.auditId)
|
|
4545
|
+
flags.push(`audit=${snap.auditId}`);
|
|
4546
|
+
const suffix = flags.length ? ` (${flags.join(", ")})` : "";
|
|
4547
|
+
keyValue(snap.id, `${snap.createdAt.toISOString()}${suffix}`);
|
|
4548
|
+
}
|
|
4549
|
+
if (!options.list) {
|
|
4550
|
+
info("");
|
|
4551
|
+
info("Run 'primitive sync revert --snapshot <id>' to restore one, " +
|
|
4552
|
+
"or 'primitive sync revert' to restore the most recent.");
|
|
4553
|
+
}
|
|
4554
|
+
return;
|
|
4555
|
+
}
|
|
4556
|
+
// Resolve the target snapshot (exact id, unique prefix, or most-recent).
|
|
4557
|
+
let target;
|
|
4558
|
+
try {
|
|
4559
|
+
target = resolveSnapshot(snapshotsRoot, options.snapshot);
|
|
4560
|
+
}
|
|
4561
|
+
catch (err) {
|
|
4562
|
+
error(err?.message ?? String(err));
|
|
4563
|
+
process.exit(1);
|
|
4564
|
+
}
|
|
4565
|
+
if (!target.complete) {
|
|
4566
|
+
error(`Snapshot ${target.id} is incomplete (missing the integrity marker) and cannot be restored safely.`);
|
|
4567
|
+
process.exit(1);
|
|
4568
|
+
}
|
|
4569
|
+
// Confirmation, with a dirty-git warning.
|
|
4570
|
+
if (!options.yes) {
|
|
4571
|
+
if (await hasUncommittedChanges(configDir)) {
|
|
4572
|
+
warn(`You have uncommitted git changes under ${configDir}. ` +
|
|
4573
|
+
"Reverting will overwrite them.");
|
|
4574
|
+
}
|
|
4575
|
+
const inquirer = await import("inquirer");
|
|
4576
|
+
const { confirm } = await inquirer.default.prompt([
|
|
4577
|
+
{
|
|
4578
|
+
type: "confirm",
|
|
4579
|
+
name: "confirm",
|
|
4580
|
+
message: `Restore snapshot ${target.id} into ${configDir}? This overwrites the current sync directory.`,
|
|
4581
|
+
default: false,
|
|
4582
|
+
},
|
|
4583
|
+
]);
|
|
4584
|
+
if (!confirm) {
|
|
4585
|
+
info("Cancelled.");
|
|
4586
|
+
return;
|
|
4587
|
+
}
|
|
4588
|
+
}
|
|
4589
|
+
try {
|
|
4590
|
+
// Under legacy `--dir`, snapshotsRoot lives inside configDir; preserve
|
|
4591
|
+
// it across the full-tree swap so we don't wipe backup history.
|
|
4592
|
+
restoreSnapshot(target.path, configDir, { preserveDir: snapshotsRoot });
|
|
4593
|
+
}
|
|
4594
|
+
catch (err) {
|
|
4595
|
+
error(`Restore failed: ${err?.message ?? err}`);
|
|
4596
|
+
process.exit(1);
|
|
4597
|
+
}
|
|
4598
|
+
success(`Restored snapshot ${target.id} into ${configDir} (including .primitive-sync.json).`);
|
|
4599
|
+
info("Run 'primitive sync diff' to inspect the restored state versus the server.");
|
|
4600
|
+
});
|
|
4601
|
+
}
|
|
4602
|
+
/**
|
|
4603
|
+
* Best-effort check for uncommitted git changes under `dir`. Used only to warn
|
|
4604
|
+
* before a revert overwrites local edits — never fatal. Returns false if git
|
|
4605
|
+
* isn't available, the dir isn't in a repo, or anything goes wrong.
|
|
4606
|
+
*/
|
|
4607
|
+
async function hasUncommittedChanges(dir) {
|
|
4608
|
+
if (!existsSync(dir))
|
|
4609
|
+
return false;
|
|
4610
|
+
try {
|
|
4611
|
+
const { execSync } = await import("child_process");
|
|
4612
|
+
const out = execSync(`git status --porcelain -- "${dir}"`, {
|
|
4613
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
4614
|
+
encoding: "utf-8",
|
|
4615
|
+
});
|
|
4616
|
+
return out.trim().length > 0;
|
|
4617
|
+
}
|
|
4618
|
+
catch {
|
|
4619
|
+
return false;
|
|
4620
|
+
}
|
|
4267
4621
|
}
|
|
4268
4622
|
//# sourceMappingURL=sync.js.map
|