primitive-admin 1.1.0-alpha.74 → 1.1.0-alpha.75
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 +12 -11
- package/dist/bin/primitive.js.map +1 -1
- package/dist/src/commands/collection-type-configs.js +1 -1
- package/dist/src/commands/collection-type-configs.js.map +1 -1
- package/dist/src/commands/config.js +8 -4
- package/dist/src/commands/config.js.map +1 -1
- package/dist/src/commands/database-type-configs.js +1 -1
- package/dist/src/commands/database-type-configs.js.map +1 -1
- package/dist/src/commands/databases.js +25 -31
- package/dist/src/commands/databases.js.map +1 -1
- package/dist/src/commands/documents.js +9 -24
- package/dist/src/commands/documents.js.map +1 -1
- package/dist/src/commands/env.d.ts +1 -1
- package/dist/src/commands/env.js +14 -11
- package/dist/src/commands/env.js.map +1 -1
- package/dist/src/commands/functions.js +135 -1
- package/dist/src/commands/functions.js.map +1 -1
- package/dist/src/commands/groups.js +1 -1
- package/dist/src/commands/groups.js.map +1 -1
- package/dist/src/commands/init.d.ts +12 -15
- package/dist/src/commands/init.js +36 -77
- package/dist/src/commands/init.js.map +1 -1
- package/dist/src/commands/rule-sets.js +1 -1
- package/dist/src/commands/rule-sets.js.map +1 -1
- package/dist/src/commands/scripts.js +17 -4
- package/dist/src/commands/scripts.js.map +1 -1
- package/dist/src/commands/sync-app-settings.js +2 -4
- package/dist/src/commands/sync-app-settings.js.map +1 -1
- package/dist/src/commands/sync.d.ts +2 -2
- package/dist/src/commands/sync.js +544 -346
- package/dist/src/commands/sync.js.map +1 -1
- package/dist/src/commands/workflows.js +1 -1
- package/dist/src/lib/api-client.d.ts +7 -1
- package/dist/src/lib/api-client.js +8 -2
- package/dist/src/lib/api-client.js.map +1 -1
- package/dist/src/lib/app-match-guard.d.ts +44 -0
- package/dist/src/lib/app-match-guard.js +72 -0
- package/dist/src/lib/app-match-guard.js.map +1 -0
- package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.d.ts +11 -8
- package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.js +108 -54
- package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.js.map +1 -1
- package/dist/src/lib/config.d.ts +2 -2
- package/dist/src/lib/config.js +2 -2
- package/dist/src/lib/credentials-store.d.ts +3 -3
- package/dist/src/lib/credentials-store.js +3 -3
- package/dist/src/lib/credentials-store.js.map +1 -1
- package/dist/src/lib/document-export-permissions.d.ts +30 -0
- package/dist/src/lib/document-export-permissions.js +54 -0
- package/dist/src/lib/document-export-permissions.js.map +1 -0
- package/dist/src/lib/env-resolver-core.d.ts +41 -9
- package/dist/src/lib/env-resolver-core.js +84 -13
- package/dist/src/lib/env-resolver-core.js.map +1 -1
- package/dist/src/lib/env-resolver.d.ts +3 -3
- package/dist/src/lib/env-resolver.js +2 -2
- package/dist/src/lib/function-bundle.d.ts +11 -1
- package/dist/src/lib/function-bundle.js +13 -1
- package/dist/src/lib/function-bundle.js.map +1 -1
- package/dist/src/lib/function-collect.d.ts +101 -0
- package/dist/src/lib/function-collect.js +339 -0
- package/dist/src/lib/function-collect.js.map +1 -0
- package/dist/src/lib/function-db-types.d.ts +149 -0
- package/dist/src/lib/function-db-types.js +587 -0
- package/dist/src/lib/function-db-types.js.map +1 -0
- package/dist/src/lib/function-grants-preflight.d.ts +118 -0
- package/dist/src/lib/function-grants-preflight.js +213 -0
- package/dist/src/lib/function-grants-preflight.js.map +1 -0
- package/dist/src/lib/function-sync.d.ts +34 -0
- package/dist/src/lib/function-sync.js +103 -0
- package/dist/src/lib/function-sync.js.map +1 -1
- package/dist/src/lib/generated-config-surfaces.d.ts +388 -0
- package/dist/src/lib/generated-config-surfaces.js +757 -7
- package/dist/src/lib/generated-config-surfaces.js.map +1 -1
- package/dist/src/lib/generated-sdk-types.d.ts +12 -0
- package/dist/src/lib/generated-sdk-types.js +13 -0
- package/dist/src/lib/generated-sdk-types.js.map +1 -0
- package/dist/src/lib/init-adopt.js +2 -2
- package/dist/src/lib/init-adopt.js.map +1 -1
- package/dist/src/lib/init-assets.js +3 -3
- package/dist/src/lib/init-config.d.ts +1 -1
- package/dist/src/lib/init-ios-links.js +1 -1
- package/dist/src/lib/init-ios-links.js.map +1 -1
- package/dist/src/lib/init-plan.d.ts +1 -1
- package/dist/src/lib/init-plan.js +1 -1
- package/dist/src/lib/init-xcode.d.ts +2 -2
- package/dist/src/lib/init-xcode.js +6 -5
- package/dist/src/lib/init-xcode.js.map +1 -1
- package/dist/src/lib/local-state.d.ts +1 -1
- package/dist/src/lib/local-state.js +1 -1
- package/dist/src/lib/local-test-cases.d.ts +1 -1
- package/dist/src/lib/local-test-cases.js +2 -1
- package/dist/src/lib/local-test-cases.js.map +1 -1
- package/dist/src/lib/migration-nag.d.ts +2 -2
- package/dist/src/lib/migration-nag.js +4 -3
- package/dist/src/lib/migration-nag.js.map +1 -1
- package/dist/src/lib/project-config.d.ts +23 -20
- package/dist/src/lib/project-config.js +27 -45
- package/dist/src/lib/project-config.js.map +1 -1
- package/dist/src/lib/snapshots.d.ts +2 -2
- package/dist/src/lib/snapshots.js +2 -2
- package/dist/src/lib/swift-codegen/generator.d.ts +1 -1
- package/dist/src/lib/swift-codegen/generator.js +1 -1
- package/dist/src/lib/sync-dir-selector.d.ts +1 -1
- package/dist/src/lib/sync-dir-selector.js +1 -1
- package/dist/src/lib/sync-paths.d.ts +52 -16
- package/dist/src/lib/sync-paths.js +68 -22
- package/dist/src/lib/sync-paths.js.map +1 -1
- package/dist/src/lib/sync-resource-types.d.ts +2 -2
- package/dist/src/lib/sync-resource-types.js +2 -2
- package/dist/src/lib/template.d.ts +2 -2
- package/dist/src/lib/template.js +2 -2
- package/dist/src/lib/test-case-keys.d.ts +1 -1
- package/dist/src/lib/test-case-keys.js +1 -1
- package/dist/src/lib/workflow-codegen/generator.d.ts +1 -1
- package/dist/src/lib/workflow-codegen/generator.js +1 -1
- package/package.json +3 -3
|
@@ -9,7 +9,8 @@ import { parseMetadataCategoryToml, validateMetadataCategoryConfig, serializeMet
|
|
|
9
9
|
import { validateOperations, formatIssue, } from "../lib/toml-params-validator.js";
|
|
10
10
|
import { isPlatformOwnedWorkflow, isPlatformOwnedWorkflowKey, } from "../lib/platform-owned.js";
|
|
11
11
|
import { getServerUrl, resolveAppId } from "../lib/config.js";
|
|
12
|
-
import {
|
|
12
|
+
import { assertTreeApp } from "../lib/app-match-guard.js";
|
|
13
|
+
import { resolveSyncDir, resolveSnapshotsRoot, assertSyncStateLayout, syncStatePath, SYNC_STATE_FILENAME, isAutoResolvedSyncDir, checkLegacySyncMigration, checkLegacyDatabaseTypesDir, } from "../lib/sync-paths.js";
|
|
13
14
|
import { createSnapshot, listSnapshots, resolveSnapshot, restoreSnapshot, pruneSnapshots, } from "../lib/snapshots.js";
|
|
14
15
|
import { validateWorkflowToml, collectWorkflowTomlNotes, formatWorkflowTomlErrors, validateWorkflowExpressionsToml, validateWorkflowExpressionsRawToml, } from "../lib/workflow-toml-validator.js";
|
|
15
16
|
import { expandWorkflowTomlData } from "../lib/workflow-fragments.js";
|
|
@@ -34,8 +35,14 @@ import { buildPayloadFromToml, projectEntityToPayload, projectRecordToToml, toml
|
|
|
34
35
|
import { CONFIG_SURFACES, isEmptyForEmit, } from "../lib/generated-config-surfaces.js";
|
|
35
36
|
// #3179 — the function's two halves: the header payloads and the envelope
|
|
36
37
|
// `config push` sends, and the bytes `config pull` writes back.
|
|
37
|
-
import { applyFunctionPull, buildFunctionConfigPush, buildFunctionCreatePayload, buildFunctionUpdatePayload, functionDiffEntity, planFunctionPull, serializeFunction, } from "../lib/function-sync.js";
|
|
38
|
+
import { applyFunctionPull, buildFunctionConfigPush, buildFunctionCreatePayload, buildFunctionUpdatePayload, collectManifestForPreflight, functionDiffEntity, planFunctionPull, serializeFunction, } from "../lib/function-sync.js";
|
|
38
39
|
import { FunctionBuildError } from "../lib/function-bundle.js";
|
|
40
|
+
// #3182 — the local half of grant validation. The grammar is the vendored
|
|
41
|
+
// module the server also imports; this is where an author's own file is read.
|
|
42
|
+
import { configObjectKeysFromTree, databaseTypesFromServer, databaseTypesFromTree, functionGrantErrors, functionNeedsManifest, mergeKnownDatabaseTypes, mergeKnownGrantTargets, } from "../lib/function-grants-preflight.js";
|
|
43
|
+
// #3182 — the typed handle's generated declarations and the compiler wiring
|
|
44
|
+
// that makes an editor load them.
|
|
45
|
+
import { functionTypeWiringProblems, staleFunctionTypeArtifacts, writeFunctionTypeArtifacts, } from "../lib/function-db-types.js";
|
|
39
46
|
import { detectLayout, migrateLegacyToV2 } from "../lib/block-layout.js";
|
|
40
47
|
import { ABSENT_FROM_EXPORT, LOCAL_ONLY_ABSENT, LOCAL_ONLY_NEW, PUSH_PRUNE_SPECS, REMOTE_ONLY_UNMANAGED, REMOTE_ONLY_WILL_DELETE, SYNC_RESOURCE_TYPES, VALIDATION_ERROR, applyPrune, attachmentWritePath, attemptFetch, classifyLocalOnly, classifyPruneDeleteFailure, classifyRemoteOnly, collectPushPruneCandidates, decidePushPruneCandidate, formatPrunePlan, hasUncommittedChanges, isSafeAttachmentFilename, isSafeSyncFilename, planPrune, preserveUnwrittenEntries, removePrunedSidecar, ruleSetFileKey, } from "../lib/sync-resource-types.js";
|
|
41
48
|
import { normalizeTestCaseKey, testCaseKeyError, } from "../lib/test-case-keys.js";
|
|
@@ -68,7 +75,7 @@ function printStaleInstanceWarning(updated) {
|
|
|
68
75
|
}
|
|
69
76
|
}
|
|
70
77
|
function loadSyncState(configDir) {
|
|
71
|
-
const syncFile =
|
|
78
|
+
const syncFile = syncStatePath(configDir);
|
|
72
79
|
if (!existsSync(syncFile)) {
|
|
73
80
|
return null;
|
|
74
81
|
}
|
|
@@ -80,7 +87,7 @@ function loadSyncState(configDir) {
|
|
|
80
87
|
}
|
|
81
88
|
}
|
|
82
89
|
function saveSyncState(configDir, state) {
|
|
83
|
-
const syncFile =
|
|
90
|
+
const syncFile = syncStatePath(configDir);
|
|
84
91
|
writeFileSync(syncFile, JSON.stringify(state, null, 2));
|
|
85
92
|
}
|
|
86
93
|
/**
|
|
@@ -3402,6 +3409,13 @@ const CONFIG_DIFF_SPECS = {
|
|
|
3402
3409
|
entry: entity.entry ?? null,
|
|
3403
3410
|
durable: entity.durable === true,
|
|
3404
3411
|
limits: entity.limits ?? null,
|
|
3412
|
+
// #3182 — the grants are version-carried too, and a widened grant set is
|
|
3413
|
+
// exactly the change a reviewer must not read as Synced. Sorted because
|
|
3414
|
+
// the row stores a set.
|
|
3415
|
+
capabilities: Array.isArray(entity.capabilities)
|
|
3416
|
+
? [...entity.capabilities].map((c) => String(c)).sort()
|
|
3417
|
+
: [],
|
|
3418
|
+
unscopedReads: entity.unscopedReads === true,
|
|
3405
3419
|
}),
|
|
3406
3420
|
describe: (entity) => `function "${entity?.functionKey}"`,
|
|
3407
3421
|
},
|
|
@@ -4222,7 +4236,7 @@ export function configSemanticHash(label, filePath, maps) {
|
|
|
4222
4236
|
/**
|
|
4223
4237
|
* The sync state `config push` writes into (#2731 B6, closing #2374).
|
|
4224
4238
|
*
|
|
4225
|
-
* `loadSyncState` answers `null` for an absent
|
|
4239
|
+
* `loadSyncState` answers `null` for an absent sync-state baseline, and
|
|
4226
4240
|
* every per-entity state write in the push loops is guarded by `if (syncState)`
|
|
4227
4241
|
* — so a first push created every resource and recorded none of them. The next
|
|
4228
4242
|
* push then re-entered the create path for everything and depended on 409
|
|
@@ -6276,7 +6290,7 @@ function readSelection(options) {
|
|
|
6276
6290
|
* `registerConfigCommands` created rather than making one of its own.
|
|
6277
6291
|
*
|
|
6278
6292
|
* The module keeps its name and its ~50 exported helpers: `sync.ts`,
|
|
6279
|
-
* `resolveSyncDir`,
|
|
6293
|
+
* `resolveSyncDir`, the sync-state baseline and `primitive/<env>/`
|
|
6280
6294
|
* are internal identifiers, deliberately left alone (#2759 scope).
|
|
6281
6295
|
*/
|
|
6282
6296
|
export function registerConfigSyncCommands(sync) {
|
|
@@ -6288,8 +6302,14 @@ export function registerConfigSyncCommands(sync) {
|
|
|
6288
6302
|
.option("--app <app-id>", "App ID")
|
|
6289
6303
|
.option("--dir <path>", "Config directory (overrides the auto-resolved per-env path)")
|
|
6290
6304
|
.action(async (appId, options) => {
|
|
6305
|
+
// Refused before any tree read/write or request (#3153, F1): the tree
|
|
6306
|
+
// path no longer partitions by app.
|
|
6307
|
+
assertTreeApp(appId || options.app, "config init");
|
|
6291
6308
|
const resolvedAppId = resolveAppId(appId, options);
|
|
6292
|
-
const configDir = resolveSyncDir({
|
|
6309
|
+
const configDir = resolveSyncDir({ userDir: options.dir });
|
|
6310
|
+
// A baseline that was never renamed means an unmigrated tree; refused
|
|
6311
|
+
// here, before any request, snapshot or directory is made (#3153).
|
|
6312
|
+
assertSyncStateLayout(configDir);
|
|
6293
6313
|
if (isAutoResolvedSyncDir(options.dir)) {
|
|
6294
6314
|
info(`Using per-environment config directory: ${configDir}`);
|
|
6295
6315
|
const legacyHint = checkLegacySyncMigration(configDir);
|
|
@@ -6336,8 +6356,14 @@ export function registerConfigSyncCommands(sync) {
|
|
|
6336
6356
|
(value, previous) => [...(previous ?? []), value], [])
|
|
6337
6357
|
.option("--no-prune", "Keep local files for entities that are absent from the sync export (the next push will re-create them)")
|
|
6338
6358
|
.action(async (appId, options) => {
|
|
6359
|
+
// Refused before any tree read/write or request (#3153, F1): the tree
|
|
6360
|
+
// path no longer partitions by app.
|
|
6361
|
+
assertTreeApp(appId || options.app, "config pull");
|
|
6339
6362
|
const resolvedAppId = resolveAppId(appId, options);
|
|
6340
|
-
const configDir = resolveSyncDir({
|
|
6363
|
+
const configDir = resolveSyncDir({ userDir: options.dir });
|
|
6364
|
+
// A baseline that was never renamed means an unmigrated tree; refused
|
|
6365
|
+
// here, before any request, snapshot or directory is made (#3153).
|
|
6366
|
+
assertSyncStateLayout(configDir);
|
|
6341
6367
|
if (isAutoResolvedSyncDir(options.dir) && !options.json) {
|
|
6342
6368
|
info(`Using per-environment config directory: ${configDir}`);
|
|
6343
6369
|
const legacyHint = checkLegacySyncMigration(configDir);
|
|
@@ -7125,6 +7151,16 @@ export function registerConfigSyncCommands(sync) {
|
|
|
7125
7151
|
};
|
|
7126
7152
|
info(` Wrote database-type-configs/${filename}`);
|
|
7127
7153
|
}
|
|
7154
|
+
// #3182 — the typed-handle artifacts are REGENERATED, never pulled:
|
|
7155
|
+
// they are not in the envelope, and a fresh clone must end up with the
|
|
7156
|
+
// same three files a push would have written. Written here, after the
|
|
7157
|
+
// database types, so the declaration describes what this pull wrote.
|
|
7158
|
+
{
|
|
7159
|
+
const artifacts = writeFunctionTypeArtifacts(configDir);
|
|
7160
|
+
if (artifacts.length > 0) {
|
|
7161
|
+
info(` Wrote function types: ${artifacts.join(", ")}`);
|
|
7162
|
+
}
|
|
7163
|
+
}
|
|
7128
7164
|
// Write rule sets
|
|
7129
7165
|
const ruleSetEntities = {};
|
|
7130
7166
|
for (const ruleSet of ruleSets) {
|
|
@@ -7618,8 +7654,14 @@ export function registerConfigSyncCommands(sync) {
|
|
|
7618
7654
|
.option("-y, --yes", "Skip the interactive confirmation before pruning (for CI / automation)")
|
|
7619
7655
|
.option("--accept-warnings", "Commit schema diffs that have operations with dynamic refs (issue #666 SCHEMA_HAS_UNCHECKABLE_OPS escape hatch)")
|
|
7620
7656
|
.action(async (appId, options) => {
|
|
7657
|
+
// Refused before any tree read/write or request (#3153, F1): the tree
|
|
7658
|
+
// path no longer partitions by app.
|
|
7659
|
+
assertTreeApp(appId || options.app, "config push");
|
|
7621
7660
|
const resolvedAppId = resolveAppId(appId, options);
|
|
7622
|
-
const configDir = resolveSyncDir({
|
|
7661
|
+
const configDir = resolveSyncDir({ userDir: options.dir });
|
|
7662
|
+
// A baseline that was never renamed means an unmigrated tree; refused
|
|
7663
|
+
// here, before any request, snapshot or directory is made (#3153).
|
|
7664
|
+
assertSyncStateLayout(configDir);
|
|
7623
7665
|
if (isAutoResolvedSyncDir(options.dir)) {
|
|
7624
7666
|
info(`Using per-environment config directory: ${configDir}`);
|
|
7625
7667
|
const legacyHint = checkLegacySyncMigration(configDir);
|
|
@@ -8086,7 +8128,7 @@ export function registerConfigSyncCommands(sync) {
|
|
|
8086
8128
|
// known-bad sibling file (e.g. an invalid workflow TOML) could let an
|
|
8087
8129
|
// earlier create (a cron trigger) be applied, then abort. The
|
|
8088
8130
|
// `process.exit` also bypassed the save-on-failure catch below, so the
|
|
8089
|
-
// orphaned create never reached
|
|
8131
|
+
// orphaned create never reached the sync-state baseline and the retry
|
|
8090
8132
|
// re-issued a CREATE that the server rejected with 409 forever.
|
|
8091
8133
|
//
|
|
8092
8134
|
// Running every validation up-front means a known-bad file aborts
|
|
@@ -8268,6 +8310,100 @@ export function registerConfigSyncCommands(sync) {
|
|
|
8268
8310
|
preflightValidationErrors.push(` vars.toml: ${err?.message || String(err)}`);
|
|
8269
8311
|
}
|
|
8270
8312
|
}
|
|
8313
|
+
// #3182 — a function's database grants, checked against the author's
|
|
8314
|
+
// own file before anything is applied. The grammar and the
|
|
8315
|
+
// unscoped-read rule are purely local; whether a grant names a type and
|
|
8316
|
+
// model that will exist is answered against the tree's own
|
|
8317
|
+
// `database-type-configs/` (applied BEFORE functions since this child)
|
|
8318
|
+
// merged with the types the server already has. If the live listing
|
|
8319
|
+
// cannot be fetched and the tree declares no types, the target check is
|
|
8320
|
+
// skipped and the server — which is authoritative — has the last word.
|
|
8321
|
+
const preflightFunctionsDir = join(configDir, "functions");
|
|
8322
|
+
if (existsSync(preflightFunctionsDir)) {
|
|
8323
|
+
const functionFiles = selectFiles("function", preflightFunctionsDir, readdirSync(preflightFunctionsDir).filter((f) => f.endsWith(".toml")));
|
|
8324
|
+
if (functionFiles.length > 0) {
|
|
8325
|
+
const treeTypes = databaseTypesFromTree(configDir);
|
|
8326
|
+
let knownTypes = null;
|
|
8327
|
+
try {
|
|
8328
|
+
const live = await client.listDatabaseTypeConfigs(resolvedAppId);
|
|
8329
|
+
knownTypes = mergeKnownDatabaseTypes(databaseTypesFromServer(Array.isArray(live) ? live : []), treeTypes);
|
|
8330
|
+
}
|
|
8331
|
+
catch {
|
|
8332
|
+
knownTypes = treeTypes.size > 0 ? treeTypes : null;
|
|
8333
|
+
}
|
|
8334
|
+
// #3183 — the same question for the two grant families that name
|
|
8335
|
+
// config-tree objects. `secret:` and `var:` are absent on purpose
|
|
8336
|
+
// (D3183-002): their values are provisioned per environment, out
|
|
8337
|
+
// of band, so a tree can never answer for them.
|
|
8338
|
+
const treeTargets = configObjectKeysFromTree(configDir);
|
|
8339
|
+
let knownTargets = null;
|
|
8340
|
+
try {
|
|
8341
|
+
const [integrations, prompts] = await Promise.all([
|
|
8342
|
+
client.listIntegrations(resolvedAppId, { status: "active" }),
|
|
8343
|
+
client.listPrompts(resolvedAppId, { status: "active" }),
|
|
8344
|
+
]);
|
|
8345
|
+
knownTargets = mergeKnownGrantTargets({
|
|
8346
|
+
integrations: new Set((integrations?.items ?? [])
|
|
8347
|
+
.map((row) => String(row?.integrationKey ?? ""))
|
|
8348
|
+
.filter(Boolean)),
|
|
8349
|
+
prompts: new Set((prompts?.items ?? [])
|
|
8350
|
+
.map((row) => String(row?.promptKey ?? ""))
|
|
8351
|
+
.filter(Boolean)),
|
|
8352
|
+
}, treeTargets);
|
|
8353
|
+
}
|
|
8354
|
+
catch {
|
|
8355
|
+
knownTargets =
|
|
8356
|
+
treeTargets.integrations.size > 0 || treeTargets.prompts.size > 0
|
|
8357
|
+
? treeTargets
|
|
8358
|
+
: null;
|
|
8359
|
+
}
|
|
8360
|
+
for (const file of functionFiles) {
|
|
8361
|
+
const filePath = join(preflightFunctionsDir, file);
|
|
8362
|
+
let tomlData;
|
|
8363
|
+
try {
|
|
8364
|
+
tomlData = parseTomlFile(filePath);
|
|
8365
|
+
}
|
|
8366
|
+
catch (err) {
|
|
8367
|
+
preflightValidationErrors.push(` ${filePath}: ${err?.message || String(err)}`);
|
|
8368
|
+
continue;
|
|
8369
|
+
}
|
|
8370
|
+
// #3187 — a read grant declared without `unscopedReads = true`
|
|
8371
|
+
// is accepted when the tree's registered reads are all bound to
|
|
8372
|
+
// `$caller`, and only the MANIFEST says whether they are. So the
|
|
8373
|
+
// functions whose decision depends on it are built and collected
|
|
8374
|
+
// here, before anything is applied; the result is memoized
|
|
8375
|
+
// against the built bundle, so the push below does not run the
|
|
8376
|
+
// tree's module scope a second time (CR3187-001).
|
|
8377
|
+
let manifest = null;
|
|
8378
|
+
if (functionNeedsManifest(tomlData)) {
|
|
8379
|
+
try {
|
|
8380
|
+
manifest = await collectManifestForPreflight({
|
|
8381
|
+
configDir,
|
|
8382
|
+
// The same key the push loop derives, so a refusal names
|
|
8383
|
+
// the function the way the rest of the run does.
|
|
8384
|
+
functionKey: tomlData.function?.key || basename(file, ".toml"),
|
|
8385
|
+
tomlData,
|
|
8386
|
+
});
|
|
8387
|
+
}
|
|
8388
|
+
catch (err) {
|
|
8389
|
+
// Fail-closed and early: the push would refuse this tree for
|
|
8390
|
+
// the same reason, naming the same error.
|
|
8391
|
+
preflightValidationErrors.push(` ${filePath}: ${err?.message || String(err)}`);
|
|
8392
|
+
continue;
|
|
8393
|
+
}
|
|
8394
|
+
}
|
|
8395
|
+
for (const message of functionGrantErrors({
|
|
8396
|
+
filePath,
|
|
8397
|
+
tomlData,
|
|
8398
|
+
knownTypes,
|
|
8399
|
+
knownTargets,
|
|
8400
|
+
manifest,
|
|
8401
|
+
})) {
|
|
8402
|
+
preflightValidationErrors.push(` ${message}`);
|
|
8403
|
+
}
|
|
8404
|
+
}
|
|
8405
|
+
}
|
|
8406
|
+
}
|
|
8271
8407
|
// #2644 — reject any key the vendored definition does not recognize,
|
|
8272
8408
|
// for every migrated configuration type. Same posture the `[workflow]`
|
|
8273
8409
|
// check above has, generalized: a mistyped key fails here, in the
|
|
@@ -10865,341 +11001,86 @@ export function registerConfigSyncCommands(sync) {
|
|
|
10865
11001
|
}
|
|
10866
11002
|
}
|
|
10867
11003
|
}
|
|
10868
|
-
// Process
|
|
10869
|
-
|
|
10870
|
-
|
|
10871
|
-
|
|
10872
|
-
|
|
10873
|
-
|
|
10874
|
-
|
|
10875
|
-
|
|
10876
|
-
|
|
10877
|
-
|
|
10878
|
-
|
|
10879
|
-
|
|
10880
|
-
|
|
10881
|
-
|
|
10882
|
-
|
|
10883
|
-
|
|
10884
|
-
|
|
10885
|
-
|
|
10886
|
-
|
|
10887
|
-
|
|
10888
|
-
|
|
10889
|
-
|
|
10890
|
-
|
|
10891
|
-
|
|
10892
|
-
|
|
10893
|
-
|
|
10894
|
-
|
|
10895
|
-
|
|
10896
|
-
|
|
10897
|
-
|
|
10898
|
-
|
|
11004
|
+
// Process workflows
|
|
11005
|
+
const workflowsDir = join(configDir, "workflows");
|
|
11006
|
+
if (existsSync(workflowsDir)) {
|
|
11007
|
+
const files = selectFiles("workflow", workflowsDir, readdirSync(workflowsDir).filter((f) => f.endsWith(".toml")));
|
|
11008
|
+
// #2896 — pairing for a clone with no sync state. A workflow the
|
|
11009
|
+
// manifest has no row for is not thereby a create: the create path
|
|
11010
|
+
// below already adopts an existing workflow by key on the server's
|
|
11011
|
+
// 409 (#1006/#1174), so letting live state answer the same question
|
|
11012
|
+
// one step earlier is that same adopt — reached before a doomed
|
|
11013
|
+
// create is sent, which is what the prompt reconcile does (#2909).
|
|
11014
|
+
// Without it a stateless clone plans "create workflow" for every
|
|
11015
|
+
// workflow the server already holds, and its `.tests/` sidecars —
|
|
11016
|
+
// addressed through the sync-state id the plan never records —
|
|
11017
|
+
// cascade into duplicate test-case creates. Fetched once, and only
|
|
11018
|
+
// when a file is actually untracked; a failed listing degrades to
|
|
11019
|
+
// the create-then-409 path, which still converges.
|
|
11020
|
+
let liveWorkflowIdsByKey;
|
|
11021
|
+
const lookupLiveWorkflowId = async (workflowKey) => {
|
|
11022
|
+
if (liveWorkflowIdsByKey === undefined) {
|
|
11023
|
+
try {
|
|
11024
|
+
const live = await fetchAll((p) => client.listWorkflows(resolvedAppId, p));
|
|
11025
|
+
liveWorkflowIdsByKey = new Map(live
|
|
11026
|
+
.filter((w) => w?.workflowKey && w?.workflowId)
|
|
11027
|
+
.map((w) => [
|
|
11028
|
+
w.workflowKey,
|
|
11029
|
+
w.workflowId,
|
|
11030
|
+
]));
|
|
11031
|
+
}
|
|
11032
|
+
catch (err) {
|
|
11033
|
+
liveWorkflowIdsByKey = null;
|
|
11034
|
+
warn(` workflows: live state unavailable (${err instanceof Error ? err.message : String(err)}) — an untracked workflow plans a create and adopts on the server's conflict`);
|
|
10899
11035
|
}
|
|
10900
11036
|
}
|
|
10901
|
-
|
|
10902
|
-
functionsLiveOk = false;
|
|
10903
|
-
warnLiveUnavailable("functions", err);
|
|
10904
|
-
}
|
|
10905
|
-
}
|
|
10906
|
-
/** The version a function currently runs, or null. */
|
|
10907
|
-
const liveActiveVersion = async (functionId, activeConfigId) => {
|
|
10908
|
-
if (!activeConfigId)
|
|
10909
|
-
return null;
|
|
10910
|
-
const { items } = await client.listFunctionConfigs(resolvedAppId, functionId);
|
|
10911
|
-
return ((items || []).find((c) => String(c.configId) === String(activeConfigId)) ?? null);
|
|
11037
|
+
return liveWorkflowIdsByKey?.get(workflowKey);
|
|
10912
11038
|
};
|
|
10913
11039
|
for (const file of files) {
|
|
10914
|
-
const filePath = join(
|
|
11040
|
+
const filePath = join(workflowsDir, file);
|
|
10915
11041
|
const tomlData = parseTomlFile(filePath);
|
|
10916
|
-
|
|
10917
|
-
|
|
10918
|
-
|
|
10919
|
-
|
|
10920
|
-
//
|
|
10921
|
-
//
|
|
10922
|
-
|
|
10923
|
-
|
|
10924
|
-
|
|
10925
|
-
|
|
10926
|
-
|
|
10927
|
-
filePath,
|
|
10928
|
-
tomlData,
|
|
10929
|
-
});
|
|
10930
|
-
}
|
|
10931
|
-
catch (err) {
|
|
10932
|
-
if (!(err instanceof FunctionBuildError))
|
|
10933
|
-
throw err;
|
|
10934
|
-
applyFailures.push({
|
|
10935
|
-
type: "function",
|
|
10936
|
-
key,
|
|
10937
|
-
message: err.message,
|
|
10938
|
-
});
|
|
11042
|
+
// #1660 — skip platform-owned workflows (the reserved `__internal.*`
|
|
11043
|
+
// namespace). Resolve the key FIRST and skip BEFORE the workflow
|
|
11044
|
+
// TOML validation below, so a hollow exported `__internal.*.toml`
|
|
11045
|
+
// (steps = [], no active config) is skipped, not failed by
|
|
11046
|
+
// `validateWorkflowToml`, and does not abort the whole push trying
|
|
11047
|
+
// to activate a configuration-less workflow. Match on the resolved
|
|
11048
|
+
// `[workflow].key` (not filename) so a renamed file is still caught
|
|
11049
|
+
// (edge case 2).
|
|
11050
|
+
const platformOwnedKey = tomlData.workflow?.key || basename(file, ".toml");
|
|
11051
|
+
if (isPlatformOwnedWorkflowKey(platformOwnedKey)) {
|
|
11052
|
+
info(` Skipped workflows/${file} — platform-owned workflow "${platformOwnedKey}" (managed by the platform)`);
|
|
10939
11053
|
continue;
|
|
10940
11054
|
}
|
|
10941
|
-
//
|
|
10942
|
-
//
|
|
10943
|
-
//
|
|
10944
|
-
|
|
10945
|
-
|
|
10946
|
-
|
|
10947
|
-
|
|
10948
|
-
|
|
10949
|
-
|
|
10950
|
-
|
|
10951
|
-
|
|
10952
|
-
|
|
10953
|
-
|
|
10954
|
-
|
|
10955
|
-
|
|
10956
|
-
|
|
10957
|
-
const gate = decidePushForConfig({
|
|
10958
|
-
spec: functionSpec,
|
|
10959
|
-
maps: pushDiffMaps,
|
|
10960
|
-
localParsed: tomlData,
|
|
10961
|
-
live: liveFunction
|
|
10962
|
-
? {
|
|
10963
|
-
record: liveFunction,
|
|
10964
|
-
// The version-carried keys the remote side is projected
|
|
10965
|
-
// through; the header row does not hold them.
|
|
10966
|
-
extra: activeVersion ?? undefined,
|
|
10967
|
-
modifiedAt: liveFunction.modifiedAt,
|
|
10968
|
-
}
|
|
10969
|
-
: null,
|
|
10970
|
-
entry: existingEntry,
|
|
10971
|
-
localFileHash: computeFileHash(filePath),
|
|
10972
|
-
force: options.force,
|
|
10973
|
-
// The create leg adopts by key on the server's 409 (#1006), so
|
|
10974
|
-
// an untracked function the server already holds is that same
|
|
10975
|
-
// adopt reached one step earlier.
|
|
10976
|
-
adoptsUntrackedByKey: true,
|
|
10977
|
-
});
|
|
10978
|
-
if (gate.action === "drift" ||
|
|
10979
|
-
gate.action === "conflict" ||
|
|
10980
|
-
gate.action === "immutable") {
|
|
10981
|
-
recordDeclined("function", key, gate, existingEntry?.modifiedAt);
|
|
10982
|
-
continue;
|
|
10983
|
-
}
|
|
10984
|
-
headerAction = gate.action === "skip"
|
|
10985
|
-
? "skip"
|
|
10986
|
-
: liveFunction
|
|
10987
|
-
? "update"
|
|
10988
|
-
: "create";
|
|
10989
|
-
}
|
|
10990
|
-
else {
|
|
10991
|
-
const degraded = decideDegradedPush({
|
|
10992
|
-
force: options.force,
|
|
10993
|
-
storedContentHash: existingEntry?.contentHash,
|
|
10994
|
-
currentFileHash: computeFileHash(filePath),
|
|
10995
|
-
});
|
|
10996
|
-
if (degraded.action === "decline") {
|
|
10997
|
-
recordDeclined("function", key, { action: "live-unavailable" }, existingEntry?.modifiedAt);
|
|
10998
|
-
continue;
|
|
10999
|
-
}
|
|
11000
|
-
headerAction = degraded.action === "skip"
|
|
11001
|
-
? "skip"
|
|
11002
|
-
: liveFunction
|
|
11003
|
-
? "update"
|
|
11004
|
-
: "create";
|
|
11055
|
+
// Reject misnested headers (#685, e.g. [steps.<id>.request]) and
|
|
11056
|
+
// the #1447 runAs="system" + accessRule dead-config combo before
|
|
11057
|
+
// pushing. The runtime silently ignores fields outside the
|
|
11058
|
+
// allowlist, so this is the only place to catch the footgun, and
|
|
11059
|
+
// the server rejects a system accessRule (#1258) — flagging it
|
|
11060
|
+
// here keeps the push all-or-nothing. Validation happens BEFORE the
|
|
11061
|
+
// skip-if-unchanged check so a previously-pushed-but-broken
|
|
11062
|
+
// file gets a clear diagnostic on every push attempt.
|
|
11063
|
+
// Issue #976: validation is now front-loaded before any apply
|
|
11064
|
+
// (see the pre-flight pass above). This in-loop check remains as
|
|
11065
|
+
// defense-in-depth, but THROWS rather than `process.exit(1)` so the
|
|
11066
|
+
// single save-on-failure catch is the only exit path — never
|
|
11067
|
+
// bypassing it and orphaning an already-created resource.
|
|
11068
|
+
const tomlErrors = validateWorkflowToml(tomlData);
|
|
11069
|
+
if (tomlErrors.length > 0) {
|
|
11070
|
+
throw new Error(formatWorkflowTomlErrors(filePath, tomlErrors));
|
|
11005
11071
|
}
|
|
11006
|
-
|
|
11007
|
-
|
|
11008
|
-
|
|
11009
|
-
!!activeVersion &&
|
|
11010
|
-
String(activeVersion.envelopeHash ?? "") === push.envelopeHash;
|
|
11011
|
-
if (headerAction === "skip" && codeUnchanged) {
|
|
11012
|
-
skippedCount++;
|
|
11013
|
-
if (syncState && liveFunction?.functionId) {
|
|
11014
|
-
if (!syncState.entities.functions)
|
|
11015
|
-
syncState.entities.functions = {};
|
|
11016
|
-
syncState.entities.functions[key] = {
|
|
11017
|
-
...(existingEntry ?? {}),
|
|
11018
|
-
id: liveFunction.functionId,
|
|
11019
|
-
modifiedAt: liveFunction.modifiedAt ||
|
|
11020
|
-
existingEntry?.modifiedAt ||
|
|
11021
|
-
new Date().toISOString(),
|
|
11022
|
-
contentHash: computeFileHash(filePath),
|
|
11023
|
-
semanticHash: hashRemoteConfigForDiff(functionSpec, liveFunction, pushDiffMaps, activeVersion ?? undefined),
|
|
11024
|
-
envelopeHash: push.envelopeHash,
|
|
11025
|
-
activeConfigId: activeVersion?.configId,
|
|
11026
|
-
sources: push.sources.map((src) => src.path),
|
|
11027
|
-
};
|
|
11028
|
-
}
|
|
11029
|
-
continue;
|
|
11072
|
+
const unknownKeyError = workflowUnknownKeyError(filePath, tomlData);
|
|
11073
|
+
if (unknownKeyError) {
|
|
11074
|
+
throw new Error(unknownKeyError);
|
|
11030
11075
|
}
|
|
11031
|
-
|
|
11032
|
-
|
|
11033
|
-
|
|
11034
|
-
|
|
11035
|
-
|
|
11036
|
-
|
|
11037
|
-
|
|
11038
|
-
|
|
11039
|
-
// the loop moves on. A single archived tombstone — or a key the
|
|
11040
|
-
// app's namespace already holds — must not abort a push carrying
|
|
11041
|
-
// unrelated changes: the run reports what it could not apply, exits
|
|
11042
|
-
// non-zero, and everything else in the tree still lands. That is
|
|
11043
|
-
// the same contract `applyFailures` gives every other type; a throw
|
|
11044
|
-
// here would let one refusal block a whole repository.
|
|
11045
|
-
let functionId = liveFunction?.functionId ?? existingEntry?.id;
|
|
11046
|
-
let pushedVersion;
|
|
11047
|
-
try {
|
|
11048
|
-
if (!functionId) {
|
|
11049
|
-
const created = await client.createFunction(resolvedAppId, buildFunctionCreatePayload(key, tomlData.function ?? {})).catch(async (err) => {
|
|
11050
|
-
// #1006 — a function the server already holds under this key
|
|
11051
|
-
// is adopted rather than re-created. The conflict may name a
|
|
11052
|
-
// WORKFLOW, a script or a webhook instead: those are the
|
|
11053
|
-
// single key namespace refusing the key, and they are not
|
|
11054
|
-
// adoptable, so `adoptByKeyOnCreate409` re-throws the original
|
|
11055
|
-
// error with its holder named.
|
|
11056
|
-
const existing = await adoptByKeyOnCreate409({
|
|
11057
|
-
err,
|
|
11058
|
-
kind: "function",
|
|
11059
|
-
key,
|
|
11060
|
-
lookup: async () => (await client.listFunctions(resolvedAppId)).items || [],
|
|
11061
|
-
matchKey: (f) => f?.functionKey === key,
|
|
11062
|
-
});
|
|
11063
|
-
return existing;
|
|
11064
|
-
});
|
|
11065
|
-
functionId = created.functionId;
|
|
11066
|
-
info(` Created function: ${key}`);
|
|
11067
|
-
}
|
|
11068
|
-
else if (headerAction !== "skip") {
|
|
11069
|
-
const updatePayload = buildFunctionUpdatePayload(tomlData.function ?? {});
|
|
11070
|
-
if (Object.keys(updatePayload).length > 0) {
|
|
11071
|
-
await client.updateFunction(resolvedAppId, functionId, updatePayload);
|
|
11072
|
-
}
|
|
11073
|
-
info(` Updated function: ${key}`);
|
|
11074
|
-
}
|
|
11075
|
-
// The version. `status` is NEVER in this body: pushing code to a
|
|
11076
|
-
// disabled function updates it and leaves it out of service
|
|
11077
|
-
// (#2803's resurrection guard), and a push to an archived one is
|
|
11078
|
-
// refused by the server with the archived-holder message — which
|
|
11079
|
-
// names the remedies that release its key.
|
|
11080
|
-
pushedVersion = await client.pushFunctionConfig(resolvedAppId, String(functionId), push);
|
|
11081
|
-
}
|
|
11082
|
-
catch (err) {
|
|
11083
|
-
applyFailures.push({
|
|
11084
|
-
type: "function",
|
|
11085
|
-
key,
|
|
11086
|
-
message: String(err?.message ?? err),
|
|
11087
|
-
});
|
|
11088
|
-
continue;
|
|
11089
|
-
}
|
|
11090
|
-
if (pushedVersion?.created === false) {
|
|
11091
|
-
info(` Function ${key}: code unchanged (no new version)`);
|
|
11092
|
-
}
|
|
11093
|
-
else {
|
|
11094
|
-
info(` Pushed function ${key} version ${pushedVersion?.configId ?? ""}`.trim());
|
|
11095
|
-
}
|
|
11096
|
-
if (syncState) {
|
|
11097
|
-
if (!syncState.entities.functions)
|
|
11098
|
-
syncState.entities.functions = {};
|
|
11099
|
-
let refreshed = liveFunction;
|
|
11100
|
-
try {
|
|
11101
|
-
refreshed = await client.getFunction(resolvedAppId, String(functionId));
|
|
11102
|
-
}
|
|
11103
|
-
catch {
|
|
11104
|
-
// Non-fatal: the baseline falls back to what the listing said.
|
|
11105
|
-
// The next push reconciles it.
|
|
11106
|
-
}
|
|
11107
|
-
syncState.entities.functions[key] = {
|
|
11108
|
-
id: String(functionId),
|
|
11109
|
-
modifiedAt: refreshed?.modifiedAt ||
|
|
11110
|
-
existingEntry?.modifiedAt ||
|
|
11111
|
-
new Date().toISOString(),
|
|
11112
|
-
contentHash: computeFileHash(filePath),
|
|
11113
|
-
semanticHash: refreshed
|
|
11114
|
-
? hashRemoteConfigForDiff(functionSpec, refreshed, pushDiffMaps, pushedVersion ?? undefined)
|
|
11115
|
-
: undefined,
|
|
11116
|
-
envelopeHash: push.envelopeHash,
|
|
11117
|
-
activeConfigId: pushedVersion?.configId,
|
|
11118
|
-
sources: push.sources.map((src) => src.path),
|
|
11119
|
-
};
|
|
11120
|
-
}
|
|
11121
|
-
}
|
|
11122
|
-
}
|
|
11123
|
-
// Process workflows
|
|
11124
|
-
const workflowsDir = join(configDir, "workflows");
|
|
11125
|
-
if (existsSync(workflowsDir)) {
|
|
11126
|
-
const files = selectFiles("workflow", workflowsDir, readdirSync(workflowsDir).filter((f) => f.endsWith(".toml")));
|
|
11127
|
-
// #2896 — pairing for a clone with no sync state. A workflow the
|
|
11128
|
-
// manifest has no row for is not thereby a create: the create path
|
|
11129
|
-
// below already adopts an existing workflow by key on the server's
|
|
11130
|
-
// 409 (#1006/#1174), so letting live state answer the same question
|
|
11131
|
-
// one step earlier is that same adopt — reached before a doomed
|
|
11132
|
-
// create is sent, which is what the prompt reconcile does (#2909).
|
|
11133
|
-
// Without it a stateless clone plans "create workflow" for every
|
|
11134
|
-
// workflow the server already holds, and its `.tests/` sidecars —
|
|
11135
|
-
// addressed through the sync-state id the plan never records —
|
|
11136
|
-
// cascade into duplicate test-case creates. Fetched once, and only
|
|
11137
|
-
// when a file is actually untracked; a failed listing degrades to
|
|
11138
|
-
// the create-then-409 path, which still converges.
|
|
11139
|
-
let liveWorkflowIdsByKey;
|
|
11140
|
-
const lookupLiveWorkflowId = async (workflowKey) => {
|
|
11141
|
-
if (liveWorkflowIdsByKey === undefined) {
|
|
11142
|
-
try {
|
|
11143
|
-
const live = await fetchAll((p) => client.listWorkflows(resolvedAppId, p));
|
|
11144
|
-
liveWorkflowIdsByKey = new Map(live
|
|
11145
|
-
.filter((w) => w?.workflowKey && w?.workflowId)
|
|
11146
|
-
.map((w) => [
|
|
11147
|
-
w.workflowKey,
|
|
11148
|
-
w.workflowId,
|
|
11149
|
-
]));
|
|
11150
|
-
}
|
|
11151
|
-
catch (err) {
|
|
11152
|
-
liveWorkflowIdsByKey = null;
|
|
11153
|
-
warn(` workflows: live state unavailable (${err instanceof Error ? err.message : String(err)}) — an untracked workflow plans a create and adopts on the server's conflict`);
|
|
11154
|
-
}
|
|
11155
|
-
}
|
|
11156
|
-
return liveWorkflowIdsByKey?.get(workflowKey);
|
|
11157
|
-
};
|
|
11158
|
-
for (const file of files) {
|
|
11159
|
-
const filePath = join(workflowsDir, file);
|
|
11160
|
-
const tomlData = parseTomlFile(filePath);
|
|
11161
|
-
// #1660 — skip platform-owned workflows (the reserved `__internal.*`
|
|
11162
|
-
// namespace). Resolve the key FIRST and skip BEFORE the workflow
|
|
11163
|
-
// TOML validation below, so a hollow exported `__internal.*.toml`
|
|
11164
|
-
// (steps = [], no active config) is skipped, not failed by
|
|
11165
|
-
// `validateWorkflowToml`, and does not abort the whole push trying
|
|
11166
|
-
// to activate a configuration-less workflow. Match on the resolved
|
|
11167
|
-
// `[workflow].key` (not filename) so a renamed file is still caught
|
|
11168
|
-
// (edge case 2).
|
|
11169
|
-
const platformOwnedKey = tomlData.workflow?.key || basename(file, ".toml");
|
|
11170
|
-
if (isPlatformOwnedWorkflowKey(platformOwnedKey)) {
|
|
11171
|
-
info(` Skipped workflows/${file} — platform-owned workflow "${platformOwnedKey}" (managed by the platform)`);
|
|
11172
|
-
continue;
|
|
11173
|
-
}
|
|
11174
|
-
// Reject misnested headers (#685, e.g. [steps.<id>.request]) and
|
|
11175
|
-
// the #1447 runAs="system" + accessRule dead-config combo before
|
|
11176
|
-
// pushing. The runtime silently ignores fields outside the
|
|
11177
|
-
// allowlist, so this is the only place to catch the footgun, and
|
|
11178
|
-
// the server rejects a system accessRule (#1258) — flagging it
|
|
11179
|
-
// here keeps the push all-or-nothing. Validation happens BEFORE the
|
|
11180
|
-
// skip-if-unchanged check so a previously-pushed-but-broken
|
|
11181
|
-
// file gets a clear diagnostic on every push attempt.
|
|
11182
|
-
// Issue #976: validation is now front-loaded before any apply
|
|
11183
|
-
// (see the pre-flight pass above). This in-loop check remains as
|
|
11184
|
-
// defense-in-depth, but THROWS rather than `process.exit(1)` so the
|
|
11185
|
-
// single save-on-failure catch is the only exit path — never
|
|
11186
|
-
// bypassing it and orphaning an already-created resource.
|
|
11187
|
-
const tomlErrors = validateWorkflowToml(tomlData);
|
|
11188
|
-
if (tomlErrors.length > 0) {
|
|
11189
|
-
throw new Error(formatWorkflowTomlErrors(filePath, tomlErrors));
|
|
11190
|
-
}
|
|
11191
|
-
const unknownKeyError = workflowUnknownKeyError(filePath, tomlData);
|
|
11192
|
-
if (unknownKeyError) {
|
|
11193
|
-
throw new Error(unknownKeyError);
|
|
11194
|
-
}
|
|
11195
|
-
const workflow = tomlData.workflow || {};
|
|
11196
|
-
const key = workflow.key || basename(file, ".toml");
|
|
11197
|
-
const steps = tomlData.steps || [];
|
|
11198
|
-
// #2652 — a caller workflow with no rule denies every non-admin
|
|
11199
|
-
// start from this release onward. A `runAs = "system"` workflow is
|
|
11200
|
-
// exempt (it rejects rules and members cannot start it).
|
|
11201
|
-
const workflowRuleWarning = missingAccessRuleWarning({
|
|
11202
|
-
kind: "workflow",
|
|
11076
|
+
const workflow = tomlData.workflow || {};
|
|
11077
|
+
const key = workflow.key || basename(file, ".toml");
|
|
11078
|
+
const steps = tomlData.steps || [];
|
|
11079
|
+
// #2652 — a caller workflow with no rule denies every non-admin
|
|
11080
|
+
// start from this release onward. A `runAs = "system"` workflow is
|
|
11081
|
+
// exempt (it rejects rules and members cannot start it).
|
|
11082
|
+
const workflowRuleWarning = missingAccessRuleWarning({
|
|
11083
|
+
kind: "workflow",
|
|
11203
11084
|
key,
|
|
11204
11085
|
accessRule: workflow.accessRule,
|
|
11205
11086
|
runAs: workflow.runAs,
|
|
@@ -12509,7 +12390,7 @@ export function registerConfigSyncCommands(sync) {
|
|
|
12509
12390
|
//
|
|
12510
12391
|
// But the 409 also fires when the local sync state is merely
|
|
12511
12392
|
// ABSENT for a type that another admin has already FULLY
|
|
12512
|
-
// configured (or whose
|
|
12393
|
+
// configured (or whose sync-state baseline was not copied
|
|
12513
12394
|
// alongside the TOML). PATCHing that without
|
|
12514
12395
|
// `expectedModifiedAt` would silently overwrite the existing
|
|
12515
12396
|
// triggers/access/schema with the local TOML — with no
|
|
@@ -12953,6 +12834,279 @@ export function registerConfigSyncCommands(sync) {
|
|
|
12953
12834
|
}
|
|
12954
12835
|
}
|
|
12955
12836
|
}
|
|
12837
|
+
// Process server functions — #3179.
|
|
12838
|
+
//
|
|
12839
|
+
// A function pushes on TWO axes and they are deliberately independent:
|
|
12840
|
+
//
|
|
12841
|
+
// - the HEADER (`[function]`'s gate, runAs, schemas, plus the three
|
|
12842
|
+
// version-carried keys, all compared through the shared gate), and
|
|
12843
|
+
// - the CODE — the authored TOML bytes, every source file and the
|
|
12844
|
+
// built bundle, sent as one immutable config VERSION.
|
|
12845
|
+
//
|
|
12846
|
+
// Independent because a comment-only edit to a source file leaves the
|
|
12847
|
+
// header identical and must still make a version (F-003): gating the
|
|
12848
|
+
// code push on the header comparison would silently discard it. The
|
|
12849
|
+
// code axis compares `envelopeHash`, which is also what the SERVER
|
|
12850
|
+
// dedupes on, so an unchanged push makes no version on either side's
|
|
12851
|
+
// reckoning.
|
|
12852
|
+
//
|
|
12853
|
+
// The bundle is built FIRST, so a build error, a missing entry or an
|
|
12854
|
+
// import that escapes the config tree aborts this function before
|
|
12855
|
+
// anything is sent for it (behavior 12).
|
|
12856
|
+
const functionsPushDir = join(configDir, "functions");
|
|
12857
|
+
if (existsSync(functionsPushDir)) {
|
|
12858
|
+
// #3182 — the typed handle. Written BEFORE the bundles are built, so
|
|
12859
|
+
// an author who runs `config push` after editing a schema gets the
|
|
12860
|
+
// declaration their next edit is typed against, and written from the
|
|
12861
|
+
// TREE rather than from the server: the types describe the schemas
|
|
12862
|
+
// this push is applying. Regenerated every time and never part of the
|
|
12863
|
+
// envelope — `config pull` recreates them the same way.
|
|
12864
|
+
if (!options.dryRun) {
|
|
12865
|
+
const artifacts = writeFunctionTypeArtifacts(configDir);
|
|
12866
|
+
if (artifacts.length > 0) {
|
|
12867
|
+
info(` Wrote function types: ${artifacts.join(", ")}`);
|
|
12868
|
+
}
|
|
12869
|
+
}
|
|
12870
|
+
// A tsconfig push will not rewrite (comments are the author's) or
|
|
12871
|
+
// cannot read is still a tsconfig no compiler loads the handle
|
|
12872
|
+
// through. Say so rather than report success over it (CR3182-003).
|
|
12873
|
+
for (const problem of functionTypeWiringProblems(configDir)) {
|
|
12874
|
+
warn(` functions/tsconfig.json: ${problem}`);
|
|
12875
|
+
}
|
|
12876
|
+
const files = selectFiles("function", functionsPushDir, readdirSync(functionsPushDir).filter((f) => f.endsWith(".toml")));
|
|
12877
|
+
const functionSpec = configDiffSpec("function");
|
|
12878
|
+
const liveFunctions = new Map();
|
|
12879
|
+
let functionsLiveOk = true;
|
|
12880
|
+
if (files.length > 0) {
|
|
12881
|
+
try {
|
|
12882
|
+
const listed = await client.listFunctions(resolvedAppId);
|
|
12883
|
+
for (const f of (listed?.items ?? [])) {
|
|
12884
|
+
if (f?.functionKey)
|
|
12885
|
+
liveFunctions.set(f.functionKey, f);
|
|
12886
|
+
}
|
|
12887
|
+
}
|
|
12888
|
+
catch (err) {
|
|
12889
|
+
functionsLiveOk = false;
|
|
12890
|
+
warnLiveUnavailable("functions", err);
|
|
12891
|
+
}
|
|
12892
|
+
}
|
|
12893
|
+
/** The version a function currently runs, or null. */
|
|
12894
|
+
const liveActiveVersion = async (functionId, activeConfigId) => {
|
|
12895
|
+
if (!activeConfigId)
|
|
12896
|
+
return null;
|
|
12897
|
+
const { items } = await client.listFunctionConfigs(resolvedAppId, functionId);
|
|
12898
|
+
return ((items || []).find((c) => String(c.configId) === String(activeConfigId)) ?? null);
|
|
12899
|
+
};
|
|
12900
|
+
for (const file of files) {
|
|
12901
|
+
const filePath = join(functionsPushDir, file);
|
|
12902
|
+
const tomlData = parseTomlFile(filePath);
|
|
12903
|
+
const key = tomlData.function?.key || basename(file, ".toml");
|
|
12904
|
+
withDerivedIdentity(tomlData, "function", { key });
|
|
12905
|
+
const existingEntry = syncState?.entities?.functions?.[key];
|
|
12906
|
+
const liveFunction = liveFunctions.get(key);
|
|
12907
|
+
// Build first: nothing is sent for a function whose code does not
|
|
12908
|
+
// compile or whose imports leave the tree.
|
|
12909
|
+
let push;
|
|
12910
|
+
try {
|
|
12911
|
+
push = await buildFunctionConfigPush({
|
|
12912
|
+
configDir,
|
|
12913
|
+
functionKey: key,
|
|
12914
|
+
filePath,
|
|
12915
|
+
tomlData,
|
|
12916
|
+
});
|
|
12917
|
+
}
|
|
12918
|
+
catch (err) {
|
|
12919
|
+
if (!(err instanceof FunctionBuildError))
|
|
12920
|
+
throw err;
|
|
12921
|
+
applyFailures.push({
|
|
12922
|
+
type: "function",
|
|
12923
|
+
key,
|
|
12924
|
+
message: err.message,
|
|
12925
|
+
});
|
|
12926
|
+
continue;
|
|
12927
|
+
}
|
|
12928
|
+
// The active version, needed by BOTH axes: the header comparison
|
|
12929
|
+
// reads `entry`/`durable`/`limits` off it, and the code axis
|
|
12930
|
+
// compares its `envelopeHash`.
|
|
12931
|
+
let activeVersion = null;
|
|
12932
|
+
let versionOk = functionsLiveOk;
|
|
12933
|
+
if (functionsLiveOk && liveFunction?.functionId) {
|
|
12934
|
+
try {
|
|
12935
|
+
activeVersion = await liveActiveVersion(liveFunction.functionId, liveFunction.activeConfigId);
|
|
12936
|
+
}
|
|
12937
|
+
catch (err) {
|
|
12938
|
+
versionOk = false;
|
|
12939
|
+
warnLiveUnavailable(`function ${key}`, err);
|
|
12940
|
+
}
|
|
12941
|
+
}
|
|
12942
|
+
let headerAction = "skip";
|
|
12943
|
+
if (versionOk && functionsLiveOk) {
|
|
12944
|
+
const gate = decidePushForConfig({
|
|
12945
|
+
spec: functionSpec,
|
|
12946
|
+
maps: pushDiffMaps,
|
|
12947
|
+
localParsed: tomlData,
|
|
12948
|
+
live: liveFunction
|
|
12949
|
+
? {
|
|
12950
|
+
record: liveFunction,
|
|
12951
|
+
// The version-carried keys the remote side is projected
|
|
12952
|
+
// through; the header row does not hold them.
|
|
12953
|
+
extra: activeVersion ?? undefined,
|
|
12954
|
+
modifiedAt: liveFunction.modifiedAt,
|
|
12955
|
+
}
|
|
12956
|
+
: null,
|
|
12957
|
+
entry: existingEntry,
|
|
12958
|
+
localFileHash: computeFileHash(filePath),
|
|
12959
|
+
force: options.force,
|
|
12960
|
+
// The create leg adopts by key on the server's 409 (#1006), so
|
|
12961
|
+
// an untracked function the server already holds is that same
|
|
12962
|
+
// adopt reached one step earlier.
|
|
12963
|
+
adoptsUntrackedByKey: true,
|
|
12964
|
+
});
|
|
12965
|
+
if (gate.action === "drift" ||
|
|
12966
|
+
gate.action === "conflict" ||
|
|
12967
|
+
gate.action === "immutable") {
|
|
12968
|
+
recordDeclined("function", key, gate, existingEntry?.modifiedAt);
|
|
12969
|
+
continue;
|
|
12970
|
+
}
|
|
12971
|
+
headerAction = gate.action === "skip"
|
|
12972
|
+
? "skip"
|
|
12973
|
+
: liveFunction
|
|
12974
|
+
? "update"
|
|
12975
|
+
: "create";
|
|
12976
|
+
}
|
|
12977
|
+
else {
|
|
12978
|
+
const degraded = decideDegradedPush({
|
|
12979
|
+
force: options.force,
|
|
12980
|
+
storedContentHash: existingEntry?.contentHash,
|
|
12981
|
+
currentFileHash: computeFileHash(filePath),
|
|
12982
|
+
});
|
|
12983
|
+
if (degraded.action === "decline") {
|
|
12984
|
+
recordDeclined("function", key, { action: "live-unavailable" }, existingEntry?.modifiedAt);
|
|
12985
|
+
continue;
|
|
12986
|
+
}
|
|
12987
|
+
headerAction = degraded.action === "skip"
|
|
12988
|
+
? "skip"
|
|
12989
|
+
: liveFunction
|
|
12990
|
+
? "update"
|
|
12991
|
+
: "create";
|
|
12992
|
+
}
|
|
12993
|
+
// The code axis. `envelopeHash` is the server's own no-op test, so
|
|
12994
|
+
// matching it here means the call would create nothing.
|
|
12995
|
+
const codeUnchanged = !!liveFunction &&
|
|
12996
|
+
!!activeVersion &&
|
|
12997
|
+
String(activeVersion.envelopeHash ?? "") === push.envelopeHash;
|
|
12998
|
+
if (headerAction === "skip" && codeUnchanged) {
|
|
12999
|
+
skippedCount++;
|
|
13000
|
+
if (syncState && liveFunction?.functionId) {
|
|
13001
|
+
if (!syncState.entities.functions)
|
|
13002
|
+
syncState.entities.functions = {};
|
|
13003
|
+
syncState.entities.functions[key] = {
|
|
13004
|
+
...(existingEntry ?? {}),
|
|
13005
|
+
id: liveFunction.functionId,
|
|
13006
|
+
modifiedAt: liveFunction.modifiedAt ||
|
|
13007
|
+
existingEntry?.modifiedAt ||
|
|
13008
|
+
new Date().toISOString(),
|
|
13009
|
+
contentHash: computeFileHash(filePath),
|
|
13010
|
+
semanticHash: hashRemoteConfigForDiff(functionSpec, liveFunction, pushDiffMaps, activeVersion ?? undefined),
|
|
13011
|
+
envelopeHash: push.envelopeHash,
|
|
13012
|
+
activeConfigId: activeVersion?.configId,
|
|
13013
|
+
sources: push.sources.map((src) => src.path),
|
|
13014
|
+
};
|
|
13015
|
+
}
|
|
13016
|
+
continue;
|
|
13017
|
+
}
|
|
13018
|
+
changes.push({
|
|
13019
|
+
type: "function",
|
|
13020
|
+
action: liveFunction ? "update" : "create",
|
|
13021
|
+
key,
|
|
13022
|
+
});
|
|
13023
|
+
if (options.dryRun)
|
|
13024
|
+
continue;
|
|
13025
|
+
// Every server refusal below is recorded against THIS function and
|
|
13026
|
+
// the loop moves on. A single archived tombstone — or a key the
|
|
13027
|
+
// app's namespace already holds — must not abort a push carrying
|
|
13028
|
+
// unrelated changes: the run reports what it could not apply, exits
|
|
13029
|
+
// non-zero, and everything else in the tree still lands. That is
|
|
13030
|
+
// the same contract `applyFailures` gives every other type; a throw
|
|
13031
|
+
// here would let one refusal block a whole repository.
|
|
13032
|
+
let functionId = liveFunction?.functionId ?? existingEntry?.id;
|
|
13033
|
+
let pushedVersion;
|
|
13034
|
+
try {
|
|
13035
|
+
if (!functionId) {
|
|
13036
|
+
const created = await client.createFunction(resolvedAppId, buildFunctionCreatePayload(key, tomlData.function ?? {})).catch(async (err) => {
|
|
13037
|
+
// #1006 — a function the server already holds under this key
|
|
13038
|
+
// is adopted rather than re-created. The conflict may name a
|
|
13039
|
+
// WORKFLOW, a script or a webhook instead: those are the
|
|
13040
|
+
// single key namespace refusing the key, and they are not
|
|
13041
|
+
// adoptable, so `adoptByKeyOnCreate409` re-throws the original
|
|
13042
|
+
// error with its holder named.
|
|
13043
|
+
const existing = await adoptByKeyOnCreate409({
|
|
13044
|
+
err,
|
|
13045
|
+
kind: "function",
|
|
13046
|
+
key,
|
|
13047
|
+
lookup: async () => (await client.listFunctions(resolvedAppId)).items || [],
|
|
13048
|
+
matchKey: (f) => f?.functionKey === key,
|
|
13049
|
+
});
|
|
13050
|
+
return existing;
|
|
13051
|
+
});
|
|
13052
|
+
functionId = created.functionId;
|
|
13053
|
+
info(` Created function: ${key}`);
|
|
13054
|
+
}
|
|
13055
|
+
else if (headerAction !== "skip") {
|
|
13056
|
+
const updatePayload = buildFunctionUpdatePayload(tomlData.function ?? {});
|
|
13057
|
+
if (Object.keys(updatePayload).length > 0) {
|
|
13058
|
+
await client.updateFunction(resolvedAppId, functionId, updatePayload);
|
|
13059
|
+
}
|
|
13060
|
+
info(` Updated function: ${key}`);
|
|
13061
|
+
}
|
|
13062
|
+
// The version. `status` is NEVER in this body: pushing code to a
|
|
13063
|
+
// disabled function updates it and leaves it out of service
|
|
13064
|
+
// (#2803's resurrection guard), and a push to an archived one is
|
|
13065
|
+
// refused by the server with the archived-holder message — which
|
|
13066
|
+
// names the remedies that release its key.
|
|
13067
|
+
pushedVersion = await client.pushFunctionConfig(resolvedAppId, String(functionId), push);
|
|
13068
|
+
}
|
|
13069
|
+
catch (err) {
|
|
13070
|
+
applyFailures.push({
|
|
13071
|
+
type: "function",
|
|
13072
|
+
key,
|
|
13073
|
+
message: String(err?.message ?? err),
|
|
13074
|
+
});
|
|
13075
|
+
continue;
|
|
13076
|
+
}
|
|
13077
|
+
if (pushedVersion?.created === false) {
|
|
13078
|
+
info(` Function ${key}: code unchanged (no new version)`);
|
|
13079
|
+
}
|
|
13080
|
+
else {
|
|
13081
|
+
info(` Pushed function ${key} version ${pushedVersion?.configId ?? ""}`.trim());
|
|
13082
|
+
}
|
|
13083
|
+
if (syncState) {
|
|
13084
|
+
if (!syncState.entities.functions)
|
|
13085
|
+
syncState.entities.functions = {};
|
|
13086
|
+
let refreshed = liveFunction;
|
|
13087
|
+
try {
|
|
13088
|
+
refreshed = await client.getFunction(resolvedAppId, String(functionId));
|
|
13089
|
+
}
|
|
13090
|
+
catch {
|
|
13091
|
+
// Non-fatal: the baseline falls back to what the listing said.
|
|
13092
|
+
// The next push reconciles it.
|
|
13093
|
+
}
|
|
13094
|
+
syncState.entities.functions[key] = {
|
|
13095
|
+
id: String(functionId),
|
|
13096
|
+
modifiedAt: refreshed?.modifiedAt ||
|
|
13097
|
+
existingEntry?.modifiedAt ||
|
|
13098
|
+
new Date().toISOString(),
|
|
13099
|
+
contentHash: computeFileHash(filePath),
|
|
13100
|
+
semanticHash: refreshed
|
|
13101
|
+
? hashRemoteConfigForDiff(functionSpec, refreshed, pushDiffMaps, pushedVersion ?? undefined)
|
|
13102
|
+
: undefined,
|
|
13103
|
+
envelopeHash: push.envelopeHash,
|
|
13104
|
+
activeConfigId: pushedVersion?.configId,
|
|
13105
|
+
sources: push.sources.map((src) => src.path),
|
|
13106
|
+
};
|
|
13107
|
+
}
|
|
13108
|
+
}
|
|
13109
|
+
}
|
|
12956
13110
|
// Process group type configs
|
|
12957
13111
|
const groupTypeConfigsDir = join(configDir, "group-type-configs");
|
|
12958
13112
|
if (existsSync(groupTypeConfigsDir)) {
|
|
@@ -13134,7 +13288,7 @@ export function registerConfigSyncCommands(sync) {
|
|
|
13134
13288
|
// this, that CREATE re-hits the server's 409 "Config for this
|
|
13135
13289
|
// group type already exists"
|
|
13136
13290
|
// (group-type-config-controller.ts) on every subsequent push
|
|
13137
|
-
// and the operator has to hand-edit
|
|
13291
|
+
// and the operator has to hand-edit the sync-state baseline to
|
|
13138
13292
|
// recover. The shared helper detects the conflict (409 status /
|
|
13139
13293
|
// "already exists" message), lists the app's configs — the list
|
|
13140
13294
|
// route drains every page server-side (#1668), so the candidate
|
|
@@ -14275,8 +14429,14 @@ export function registerConfigSyncCommands(sync) {
|
|
|
14275
14429
|
.option("--only <selectors>", "Compare just these objects: <type>/<key>, app, or var/<key> (repeatable, or comma-separated).", (value, previous) => [...(previous ?? []), value], [])
|
|
14276
14430
|
.option("--json", "Output as JSON (app.toml field diff, with --only app)")
|
|
14277
14431
|
.action(async (appId, options) => {
|
|
14432
|
+
// Refused before any tree read/write or request (#3153, F1): the tree
|
|
14433
|
+
// path no longer partitions by app.
|
|
14434
|
+
assertTreeApp(appId || options.app, "config diff");
|
|
14278
14435
|
const resolvedAppId = resolveAppId(appId, options);
|
|
14279
|
-
const configDir = resolveSyncDir({
|
|
14436
|
+
const configDir = resolveSyncDir({ userDir: options.dir });
|
|
14437
|
+
// A baseline that was never renamed means an unmigrated tree; refused
|
|
14438
|
+
// here, before any request, snapshot or directory is made (#3153).
|
|
14439
|
+
assertSyncStateLayout(configDir);
|
|
14280
14440
|
if (isAutoResolvedSyncDir(options.dir)) {
|
|
14281
14441
|
info(`Using per-environment config directory: ${configDir}`);
|
|
14282
14442
|
const legacyHint = checkLegacySyncMigration(configDir);
|
|
@@ -15650,6 +15810,29 @@ export function registerConfigSyncCommands(sync) {
|
|
|
15650
15810
|
console.log(` ${chalk.dim("=")} ${d.blockType}/${d.blockKey} test-case: ${d.slug}${hint}`);
|
|
15651
15811
|
}
|
|
15652
15812
|
}
|
|
15813
|
+
// #3182 — the generated function types are part of what a push would
|
|
15814
|
+
// change, so `config diff` is where their freshness is answered: an
|
|
15815
|
+
// editor or a CI job asks the same command it already asks "what would
|
|
15816
|
+
// push do?", and gets these named alongside everything else.
|
|
15817
|
+
const staleFunctionTypes = staleFunctionTypeArtifacts(configDir);
|
|
15818
|
+
if (staleFunctionTypes.length > 0) {
|
|
15819
|
+
console.log();
|
|
15820
|
+
info("Function types (regenerated by `config push`):");
|
|
15821
|
+
for (const relative of staleFunctionTypes) {
|
|
15822
|
+
console.log(` ${chalk.yellow("~")} ${relative}`);
|
|
15823
|
+
}
|
|
15824
|
+
}
|
|
15825
|
+
// Freshness is not the only way the handle stops working: a tsconfig
|
|
15826
|
+
// push will not rewrite can be perfectly current and still load none of
|
|
15827
|
+
// it. `config diff` is where CI asks, so it answers both (CR3182-003).
|
|
15828
|
+
const wiringProblems = functionTypeWiringProblems(configDir);
|
|
15829
|
+
if (wiringProblems.length > 0) {
|
|
15830
|
+
console.log();
|
|
15831
|
+
info("Function types (compiler wiring `config push` cannot repair):");
|
|
15832
|
+
for (const problem of wiringProblems) {
|
|
15833
|
+
console.log(` ${chalk.yellow("!")} functions/tsconfig.json: ${problem}`);
|
|
15834
|
+
}
|
|
15835
|
+
}
|
|
15653
15836
|
divider();
|
|
15654
15837
|
keyValue("Local only (new)", localOnly.length);
|
|
15655
15838
|
keyValue("Local only (absent from export)", localOnlyAbsent.length);
|
|
@@ -15698,8 +15881,14 @@ export function registerConfigSyncCommands(sync) {
|
|
|
15698
15881
|
// Resolve appId so we land in the per-env sync dir even when the user
|
|
15699
15882
|
// didn't pass `--dir`. The app ID itself isn't used for any API calls
|
|
15700
15883
|
// — `migrate-toml` is a purely local rewrite.
|
|
15884
|
+
// Refused before any tree read/write or request (#3153, F1): the tree
|
|
15885
|
+
// path no longer partitions by app.
|
|
15886
|
+
assertTreeApp(appId || options.app, "config migrate-toml");
|
|
15701
15887
|
const resolvedAppId = resolveAppId(appId, options);
|
|
15702
|
-
const configDir = resolveSyncDir({
|
|
15888
|
+
const configDir = resolveSyncDir({ userDir: options.dir });
|
|
15889
|
+
// A baseline that was never renamed means an unmigrated tree; refused
|
|
15890
|
+
// here, before any request, snapshot or directory is made (#3153).
|
|
15891
|
+
assertSyncStateLayout(configDir);
|
|
15703
15892
|
if (isAutoResolvedSyncDir(options.dir)) {
|
|
15704
15893
|
info(`Using per-environment config directory: ${configDir}`);
|
|
15705
15894
|
}
|
|
@@ -15862,8 +16051,14 @@ export function registerConfigSyncCommands(sync) {
|
|
|
15862
16051
|
// Resolve appId so we land in the per-env sync dir even when the user
|
|
15863
16052
|
// didn't pass `--dir`. The app ID itself isn't used for any API calls —
|
|
15864
16053
|
// migrate-v2 is a purely local rewrite (like migrate-toml).
|
|
16054
|
+
// Refused before any tree read/write or request (#3153, F1): the tree
|
|
16055
|
+
// path no longer partitions by app.
|
|
16056
|
+
assertTreeApp(appId || options.app, "config migrate-v2");
|
|
15865
16057
|
const resolvedAppId = resolveAppId(appId, options);
|
|
15866
|
-
const configDir = resolveSyncDir({
|
|
16058
|
+
const configDir = resolveSyncDir({ userDir: options.dir });
|
|
16059
|
+
// A baseline that was never renamed means an unmigrated tree; refused
|
|
16060
|
+
// here, before any request, snapshot or directory is made (#3153).
|
|
16061
|
+
assertSyncStateLayout(configDir);
|
|
15867
16062
|
if (isAutoResolvedSyncDir(options.dir)) {
|
|
15868
16063
|
info(`Using per-environment config directory: ${configDir}`);
|
|
15869
16064
|
}
|
|
@@ -15913,11 +16108,14 @@ export function registerConfigSyncCommands(sync) {
|
|
|
15913
16108
|
.option("--list", "List available snapshots without restoring")
|
|
15914
16109
|
.option("-y, --yes", "Skip the confirmation prompt")
|
|
15915
16110
|
.action(async (appId, options) => {
|
|
16111
|
+
// Refused before any tree read/write or request (#3153, F1): the tree
|
|
16112
|
+
// path no longer partitions by app.
|
|
16113
|
+
assertTreeApp(appId || options.app, "config revert");
|
|
15916
16114
|
const resolvedAppId = resolveAppId(appId, options);
|
|
15917
|
-
const configDir = resolveSyncDir({
|
|
15918
|
-
|
|
15919
|
-
|
|
15920
|
-
|
|
16115
|
+
const configDir = resolveSyncDir({ userDir: options.dir });
|
|
16116
|
+
// A baseline that was never renamed means an unmigrated tree; refused
|
|
16117
|
+
// here, before any request, snapshot or directory is made (#3153).
|
|
16118
|
+
assertSyncStateLayout(configDir);
|
|
15921
16119
|
const snapshotsRoot = resolveSnapshotsRoot({
|
|
15922
16120
|
appId: resolvedAppId,
|
|
15923
16121
|
userDir: options.dir,
|
|
@@ -15992,7 +16190,7 @@ export function registerConfigSyncCommands(sync) {
|
|
|
15992
16190
|
error(`Restore failed: ${err?.message ?? err}`);
|
|
15993
16191
|
process.exit(1);
|
|
15994
16192
|
}
|
|
15995
|
-
success(`Restored snapshot ${target.id} into ${configDir} (including
|
|
16193
|
+
success(`Restored snapshot ${target.id} into ${configDir} (including ${SYNC_STATE_FILENAME}).`);
|
|
15996
16194
|
info("Run 'primitive config diff' to inspect the restored state versus the server.");
|
|
15997
16195
|
});
|
|
15998
16196
|
}
|