primitive-admin 1.1.0-alpha.78 → 1.1.0-alpha.79

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/README.md +38 -17
  2. package/assets/skill/skills/primitive-platform/SKILL.md +39 -52
  3. package/dist/bin/primitive.js +14 -25
  4. package/dist/bin/primitive.js.map +1 -1
  5. package/dist/src/commands/admins.js +8 -18
  6. package/dist/src/commands/admins.js.map +1 -1
  7. package/dist/src/commands/analytics.js +46 -5
  8. package/dist/src/commands/analytics.js.map +1 -1
  9. package/dist/src/commands/auth.js +16 -1
  10. package/dist/src/commands/auth.js.map +1 -1
  11. package/dist/src/commands/collection-type-configs.js +1 -9
  12. package/dist/src/commands/collection-type-configs.js.map +1 -1
  13. package/dist/src/commands/config.js +11 -29
  14. package/dist/src/commands/config.js.map +1 -1
  15. package/dist/src/commands/database-type-configs.js +1 -9
  16. package/dist/src/commands/database-type-configs.js.map +1 -1
  17. package/dist/src/commands/databases.js +8 -38
  18. package/dist/src/commands/databases.js.map +1 -1
  19. package/dist/src/commands/env.js +27 -32
  20. package/dist/src/commands/env.js.map +1 -1
  21. package/dist/src/commands/functions.js +197 -23
  22. package/dist/src/commands/functions.js.map +1 -1
  23. package/dist/src/commands/groups.js +1 -9
  24. package/dist/src/commands/groups.js.map +1 -1
  25. package/dist/src/commands/init.js +6 -0
  26. package/dist/src/commands/init.js.map +1 -1
  27. package/dist/src/commands/rule-sets.js +1 -9
  28. package/dist/src/commands/rule-sets.js.map +1 -1
  29. package/dist/src/commands/scripts.js +12 -28
  30. package/dist/src/commands/scripts.js.map +1 -1
  31. package/dist/src/commands/sync.d.ts +119 -0
  32. package/dist/src/commands/sync.js +387 -196
  33. package/dist/src/commands/sync.js.map +1 -1
  34. package/dist/src/commands/workflows.js +2 -14
  35. package/dist/src/commands/workflows.js.map +1 -1
  36. package/dist/src/lib/api-client.d.ts +46 -11
  37. package/dist/src/lib/api-client.js +21 -11
  38. package/dist/src/lib/api-client.js.map +1 -1
  39. package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.d.ts +24 -57
  40. package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.js +30 -204
  41. package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.js.map +1 -1
  42. package/dist/src/lib/config-json-field.d.ts +28 -0
  43. package/dist/src/lib/config-json-field.js +56 -0
  44. package/dist/src/lib/config-json-field.js.map +1 -0
  45. package/dist/src/lib/config-object-descriptor.js +4 -3
  46. package/dist/src/lib/config-object-descriptor.js.map +1 -1
  47. package/dist/src/lib/config-surface.js +1 -1
  48. package/dist/src/lib/config-surface.js.map +1 -1
  49. package/dist/src/lib/config.d.ts +21 -14
  50. package/dist/src/lib/config.js +25 -44
  51. package/dist/src/lib/config.js.map +1 -1
  52. package/dist/src/lib/credentials-store.d.ts +31 -8
  53. package/dist/src/lib/credentials-store.js +52 -13
  54. package/dist/src/lib/credentials-store.js.map +1 -1
  55. package/dist/src/lib/env-resolver-core.d.ts +18 -0
  56. package/dist/src/lib/env-resolver-core.js +26 -3
  57. package/dist/src/lib/env-resolver-core.js.map +1 -1
  58. package/dist/src/lib/env-resolver.d.ts +15 -0
  59. package/dist/src/lib/env-resolver.js +21 -1
  60. package/dist/src/lib/env-resolver.js.map +1 -1
  61. package/dist/src/lib/function-collect.d.ts +1 -1
  62. package/dist/src/lib/function-collect.js +138 -7
  63. package/dist/src/lib/function-collect.js.map +1 -1
  64. package/dist/src/lib/function-db-types.d.ts +49 -11
  65. package/dist/src/lib/function-db-types.js +234 -39
  66. package/dist/src/lib/function-db-types.js.map +1 -1
  67. package/dist/src/lib/function-document-types.d.ts +50 -0
  68. package/dist/src/lib/function-document-types.js +163 -0
  69. package/dist/src/lib/function-document-types.js.map +1 -0
  70. package/dist/src/lib/function-log-tail.d.ts +83 -0
  71. package/dist/src/lib/function-log-tail.js +115 -0
  72. package/dist/src/lib/function-log-tail.js.map +1 -0
  73. package/dist/src/lib/function-schema-codegen.d.ts +118 -0
  74. package/dist/src/lib/function-schema-codegen.js +402 -0
  75. package/dist/src/lib/function-schema-codegen.js.map +1 -0
  76. package/dist/src/lib/function-sync.d.ts +85 -2
  77. package/dist/src/lib/function-sync.js +196 -27
  78. package/dist/src/lib/function-sync.js.map +1 -1
  79. package/dist/src/lib/function-triggers.d.ts +9 -3
  80. package/dist/src/lib/function-triggers.js +12 -9
  81. package/dist/src/lib/function-triggers.js.map +1 -1
  82. package/dist/src/lib/function-typecheck.d.ts +86 -0
  83. package/dist/src/lib/function-typecheck.js +370 -0
  84. package/dist/src/lib/function-typecheck.js.map +1 -0
  85. package/dist/src/lib/generated-allowlist.js +4 -0
  86. package/dist/src/lib/generated-allowlist.js.map +1 -1
  87. package/dist/src/lib/generated-config-surfaces.d.ts +422 -0
  88. package/dist/src/lib/generated-config-surfaces.js +838 -9
  89. package/dist/src/lib/generated-config-surfaces.js.map +1 -1
  90. package/dist/src/lib/generated-sdk-types.d.ts +1 -1
  91. package/dist/src/lib/generated-sdk-types.js +1 -1
  92. package/dist/src/lib/generated-sdk-types.js.map +1 -1
  93. package/dist/src/lib/log-inspection.d.ts +116 -4
  94. package/dist/src/lib/log-inspection.js +147 -2
  95. package/dist/src/lib/log-inspection.js.map +1 -1
  96. package/dist/src/lib/snapshots.d.ts +6 -7
  97. package/dist/src/lib/snapshots.js +18 -81
  98. package/dist/src/lib/snapshots.js.map +1 -1
  99. package/dist/src/lib/sync-paths.d.ts +29 -53
  100. package/dist/src/lib/sync-paths.js +46 -97
  101. package/dist/src/lib/sync-paths.js.map +1 -1
  102. package/dist/src/lib/workflow-toml-validator.d.ts +8 -3
  103. package/dist/src/lib/workflow-toml-validator.js +8 -3
  104. package/dist/src/lib/workflow-toml-validator.js.map +1 -1
  105. package/package.json +3 -3
@@ -5,12 +5,14 @@ import { lookup as mimeLookup } from "mime-types";
5
5
  import { ApiClient, ApiError, ConflictError, SchemaRequiredError, OperationRefError, SchemaBreaksOpsError, SchemaHasUncheckableOpsError, TomlParseError, OpsExistError, } from "../lib/api-client.js";
6
6
  import { buildDatabaseTypeTomlData, detectExistingOperationForms, normalizeOperationFromToml, normalizeSubscriptionFromToml, SubscriptionAccessKeyConflictError, } from "../lib/toml-database-config.js";
7
7
  import { canEmitNative } from "../lib/toml-native-form.js";
8
+ // #1446's `type: "json"` emission rule, shared with `serializeFunction` since
9
+ // #3317 — see `config-json-field.ts`.
10
+ import { serializeJsonField } from "../lib/config-json-field.js";
8
11
  import { parseMetadataCategoryToml, validateMetadataCategoryConfig, serializeMetadataCategoryConfig, parseDeclaredAccessManifestToml, validateDeclaredAccessManifest, serializeDeclaredAccessManifest, } from "../lib/toml-metadata-config.js";
9
12
  import { validateOperations, formatIssue, } from "../lib/toml-params-validator.js";
10
13
  import { isPlatformOwnedWorkflow, isPlatformOwnedWorkflowKey, } from "../lib/platform-owned.js";
11
- import { getServerUrl, resolveAppId } from "../lib/config.js";
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";
14
+ import { getCurrentAppId, getServerUrl } from "../lib/config.js";
15
+ import { resolveSyncDir, resolveSnapshotsRoot, assertSyncStateLayout, syncStatePath, SYNC_STATE_FILENAME, checkLegacyDatabaseTypesDir, } from "../lib/sync-paths.js";
14
16
  import { createSnapshot, listSnapshots, resolveSnapshot, restoreSnapshot, pruneSnapshots, } from "../lib/snapshots.js";
15
17
  import { validateWorkflowToml, collectWorkflowTomlNotes, formatWorkflowTomlErrors, validateWorkflowExpressionsToml, validateWorkflowExpressionsRawToml, } from "../lib/workflow-toml-validator.js";
16
18
  import { expandWorkflowTomlData } from "../lib/workflow-fragments.js";
@@ -35,7 +37,7 @@ import { buildPayloadFromToml, projectEntityToPayload, projectRecordToToml, toml
35
37
  import { CONFIG_SURFACES, isEmptyForEmit, } from "../lib/generated-config-surfaces.js";
36
38
  // #3179 — the function's two halves: the header payloads and the envelope
37
39
  // `config push` sends, and the bytes `config pull` writes back.
38
- import { applyFunctionPull, buildFunctionConfigPush, buildFunctionCreatePayload, buildFunctionUpdatePayload, functionDiffEntity, planFunctionPull, serializeFunction, } from "../lib/function-sync.js";
40
+ import { applyFunctionPull, buildFunctionConfigPush, buildFunctionCreatePayload, buildFunctionUpdatePayload, functionDiffEntity, functionTomlPreflightErrors, planFunctionPull, serializeFunction, } from "../lib/function-sync.js";
39
41
  import { FunctionBuildError } from "../lib/function-bundle.js";
40
42
  // #3182 — the local half of capability validation. The grammar is the vendored
41
43
  // module the server also imports; this is where an author's own file is read.
@@ -43,6 +45,12 @@ import { configObjectKeysFromTree, functionGrantErrors, mergeKnownGrantTargets,
43
45
  // #3182 — the typed handle's generated declarations and the compiler wiring
44
46
  // that makes an editor load them.
45
47
  import { functionTypeWiringProblems, staleFunctionTypeArtifacts, writeFunctionTypeArtifacts, } from "../lib/function-db-types.js";
48
+ // #3281 — the functions declaration and the per-key invokers ride the same
49
+ // plan; a malformed schema is their one refusal, named by file.
50
+ import { FunctionSchemaCodegenError } from "../lib/function-schema-codegen.js";
51
+ // #3331 — and the compiler is actually asked: a push holds the code it ships
52
+ // to the declarations it just generated.
53
+ import { createFunctionTypechecker, } from "../lib/function-typecheck.js";
46
54
  import { detectLayout, migrateLegacyToV2 } from "../lib/block-layout.js";
47
55
  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";
48
56
  import { normalizeTestCaseKey, testCaseKeyError, } from "../lib/test-case-keys.js";
@@ -1946,50 +1954,6 @@ export function buildPromptConfigPayload(tomlConfig, { mode = "update" } = {}) {
1946
1954
  },
1947
1955
  });
1948
1956
  }
1949
- /**
1950
- * Emit a `type: "json"` field (a JSON Schema, a provider-options blob) as TOML.
1951
- *
1952
- * Issue #1446: pull used to `JSON.stringify` schema fields into a single
1953
- * escaped-JSON line — unreadable and undiffable. We now emit the parsed value
1954
- * as a native table whenever it round-trips faithfully
1955
- * (`canEmitNative === null`), falling back to the JSON string only for the
1956
- * values TOML silently drops (`null` / `undefined` anywhere inside). The empty
1957
- * object `{}` is a real, distinct value (for a schema: "accepts anything") and
1958
- * emits as an empty table, so it survives a pull → push cycle instead of being
1959
- * cleared server-side.
1960
- *
1961
- * Returns `undefined` for an absent value (the key is then omitted). The
1962
- * sponsor-approved decision is to always move to native aggressively — no
1963
- * form preservation — so this does not consult the existing file's form.
1964
- *
1965
- * `subject` names the object in the fallback warning, e.g. `Workflow 'greet'`.
1966
- */
1967
- function serializeJsonField(value, fieldName, subject, log) {
1968
- if (value === undefined || value === null)
1969
- return undefined;
1970
- // JSON fields arrive from the server GET parsed; defensively accept a JSON
1971
- // string too (e.g. an inline-authored file re-serialized here).
1972
- let obj = value;
1973
- if (typeof value === "string") {
1974
- try {
1975
- obj = JSON.parse(value);
1976
- }
1977
- catch {
1978
- // Not valid JSON — keep the original string as the emitted value.
1979
- return value;
1980
- }
1981
- }
1982
- if (obj && typeof obj === "object") {
1983
- const reason = canEmitNative(obj);
1984
- if (reason) {
1985
- log(`${subject}: cannot emit \`${fieldName}\` as native TOML (${reason}); using JSON string.`);
1986
- return JSON.stringify(obj);
1987
- }
1988
- return obj;
1989
- }
1990
- // A non-object value (unexpected) — preserve it as a JSON string.
1991
- return JSON.stringify(obj);
1992
- }
1993
1957
  /** The `[workflow]` case of `serializeJsonField` (#1446, message unchanged). */
1994
1958
  function serializeWorkflowSchemaField(value, fieldName, workflowKey, log) {
1995
1959
  return serializeJsonField(value, fieldName, `Workflow '${workflowKey}'`, log);
@@ -2168,6 +2132,11 @@ function localConfigRowKey(label, tomlData, file) {
2168
2132
  return stated("prompt", "key");
2169
2133
  case "workflow":
2170
2134
  return stated("workflow", "key");
2135
+ // #3320 — the declared key, like the function preflight collector, the
2136
+ // apply loop and `--only`. A renamed file is one object, so both
2137
+ // collectors' messages land on the one comparison row.
2138
+ case "function":
2139
+ return stated("function", "key");
2171
2140
  case "email-template":
2172
2141
  return stated("template", "emailType");
2173
2142
  case "database-type-config":
@@ -2251,6 +2220,75 @@ export function collectConfigFileValidationErrors(configDir) {
2251
2220
  }
2252
2221
  return found;
2253
2222
  }
2223
+ /**
2224
+ * Every `functions/<key>.toml` whose file-decidable errors `config push` would
2225
+ * refuse (#3320).
2226
+ *
2227
+ * A second collector rather than a branch inside
2228
+ * `collectConfigFileValidationErrors`, and deliberately so: push calls that one
2229
+ * UNBOUNDED over the whole tree, which is right for the checks it runs (a
2230
+ * mistyped key anywhere means the tree is not pushable) and wrong for these — a
2231
+ * `config push --only webhook/<key>` must not be blocked by a broken function
2232
+ * the operator did not select (#2645). The push path therefore runs these
2233
+ * inside its own `selectFiles`-bounded function loop, and `config diff`, which
2234
+ * compares the whole tree by construction, runs them from here.
2235
+ *
2236
+ * A file that does not parse is skipped: the per-type loop that reads it names
2237
+ * the parse error itself, and reporting it twice helps nobody.
2238
+ */
2239
+ export function collectFunctionTomlValidationErrors(configDir) {
2240
+ const found = [];
2241
+ const functionsDir = join(configDir, "functions");
2242
+ if (!existsSync(functionsDir))
2243
+ return found;
2244
+ for (const file of readdirSync(functionsDir).filter((f) => f.endsWith(".toml"))) {
2245
+ const filePath = join(functionsDir, file);
2246
+ let tomlData;
2247
+ try {
2248
+ tomlData = parseTomlFile(filePath);
2249
+ }
2250
+ catch {
2251
+ continue;
2252
+ }
2253
+ // The DECLARED key, like the apply loop and `--only` selection: a file
2254
+ // whose name and `[function].key` differ is one object, not two.
2255
+ const key = tomlData?.function?.key || basename(file, ".toml");
2256
+ const messages = functionTomlPreflightErrors({
2257
+ filePath,
2258
+ functionKey: key,
2259
+ tomlData,
2260
+ });
2261
+ if (messages.length === 0)
2262
+ continue;
2263
+ found.push({ type: "function", key, filePath, messages });
2264
+ }
2265
+ return found;
2266
+ }
2267
+ /**
2268
+ * The collectors' rows, one per diff row (#2880 symmetry, #3320).
2269
+ *
2270
+ * Two collectors reach the same function file — the definition-driven one
2271
+ * every per-entity type runs, and the function preflight — and a file can
2272
+ * break both at once (a mistyped `entrty`, which then leaves no `entry`).
2273
+ * Push reports every message from both, so an overlay that let one row's
2274
+ * messages REPLACE the other's would drop a diagnostic push prints, which is
2275
+ * exactly the asymmetry #2880 forbids. Merging by row keeps the two commands
2276
+ * reading identically.
2277
+ */
2278
+ export function mergeConfigFileValidationErrors(collected) {
2279
+ const byRow = new Map();
2280
+ for (const rejected of collected) {
2281
+ const rowId = `${rejected.type}${rejected.key}`;
2282
+ const merged = byRow.get(rowId);
2283
+ if (!merged) {
2284
+ byRow.set(rowId, { ...rejected, messages: [...rejected.messages] });
2285
+ continue;
2286
+ }
2287
+ // Collection order is the message order, the same order push prints.
2288
+ merged.messages.push(...rejected.messages);
2289
+ }
2290
+ return [...byRow.values()];
2291
+ }
2254
2292
  /**
2255
2293
  * Every `<key>.tests/*.toml` sidecar under a config directory.
2256
2294
  *
@@ -3335,6 +3373,10 @@ export function classifyPushChange(input) {
3335
3373
  const { localHash, remoteHash, baselineHash, legacy } = input;
3336
3374
  if (localHash === remoteHash)
3337
3375
  return "unchanged";
3376
+ // Ahead of both the baseline and the legacy inference, which are ways of
3377
+ // GUESSING what this states outright.
3378
+ if (input.serverUnchangedSinceSync === true)
3379
+ return "local-edited";
3338
3380
  if (baselineHash) {
3339
3381
  if (remoteHash === baselineHash)
3340
3382
  return "local-edited";
@@ -3499,7 +3541,7 @@ const CONFIG_DIFF_SPECS = {
3499
3541
  describe: (entity) => `email template "${entity?.emailType}"`,
3500
3542
  },
3501
3543
  // #3179 — server functions. The compared entity spans the header AND the
3502
- // three keys the immutable version carries (`entry`, `durable`, `limits`),
3544
+ // keys the immutable version carries (`entry`, `mode`, `limits`, …),
3503
3545
  // because an author writes one file and a version is never authored
3504
3546
  // separately: comparing only the header would report a changed entry point as
3505
3547
  // Synced. The SOURCE bytes are a second axis the table cannot express — they
@@ -3509,13 +3551,22 @@ const CONFIG_DIFF_SPECS = {
3509
3551
  table: SERVER_FUNCTION_TABLE,
3510
3552
  parse: (doc) => functionDiffEntity(doc),
3511
3553
  // `extra` is the ACTIVE config version, which is where the remote side's
3512
- // entry/durable/limits live; the header row does not carry them.
3554
+ // entry/mode/limits live; the header row does not carry them.
3513
3555
  serialize: (record, _maps, extra) => serializeFunction(record, extra),
3514
3556
  // The version-carried keys are not fields of the header table, so they
3515
3557
  // travel as extras — present on both sides or absent on both.
3516
3558
  extras: (entity) => ({
3559
+ // #3317 — `inputSchema` / `outputSchema` compared as the values they
3560
+ // encode, the rule prompts have had since #2748. A schema reaches the
3561
+ // two sides in either spelling (a native `[function.inputSchema]` table,
3562
+ // or the JSON string the server stores and the fallback pull writes for
3563
+ // a schema TOML cannot hold), and neither spelling is a difference.
3564
+ ...jsonFieldsForDiff(SERVER_FUNCTION_TABLE, entity),
3517
3565
  entry: entity.entry ?? null,
3518
- durable: entity.durable === true,
3566
+ // #3281 — the RESOLVED mode (`functionDiffEntity` reads both spellings),
3567
+ // so a legacy `durable = true` file and a server serializing `mode`
3568
+ // compare equal.
3569
+ mode: entity.mode ?? null,
3519
3570
  limits: entity.limits ?? null,
3520
3571
  // #3182 — the grants are version-carried too, and a widened grant set is
3521
3572
  // exactly the change a reviewer must not read as Synced. Sorted because
@@ -3987,17 +4038,32 @@ export function decidePushForConfig(input) {
3987
4038
  fields,
3988
4039
  };
3989
4040
  }
4041
+ // The recorded server timestamp still being the live one. It is the legacy
4042
+ // path's inference signal, and — where the row's timestamp covers the whole
4043
+ // projection — #3317's outright statement that the server did not move.
4044
+ const liveModifiedAtMatchesStored = entry?.modifiedAt !== undefined &&
4045
+ live.modifiedAt !== undefined &&
4046
+ entry.modifiedAt === live.modifiedAt;
4047
+ // The recorded version pointer still being the live one — or no pointer
4048
+ // recorded, which is no claim. A pointer that moved is a server change by
4049
+ // definition, whatever the two timestamps say (#3317: the baseline's
4050
+ // timestamp comes from a refetch a concurrent activation can precede).
4051
+ const liveActiveConfigMatchesStored = entry?.activeConfigId === undefined ||
4052
+ (live.record?.activeConfigId !== undefined &&
4053
+ live.record?.activeConfigId !== null &&
4054
+ String(entry.activeConfigId) === String(live.record.activeConfigId));
3990
4055
  const direction = classifyPushChange({
3991
4056
  localHash,
3992
4057
  remoteHash,
3993
4058
  baselineHash: entry?.semanticHash,
4059
+ serverUnchangedSinceSync: input.serverModifiedAtCoversProjection === true &&
4060
+ liveModifiedAtMatchesStored &&
4061
+ liveActiveConfigMatchesStored,
3994
4062
  legacy: {
3995
4063
  localBytesMatchStoredContentHash: entry?.contentHash !== undefined &&
3996
4064
  localFileHash !== undefined &&
3997
4065
  entry.contentHash === localFileHash,
3998
- liveModifiedAtMatchesStored: entry?.modifiedAt !== undefined &&
3999
- live.modifiedAt !== undefined &&
4000
- entry.modifiedAt === live.modifiedAt,
4066
+ liveModifiedAtMatchesStored,
4001
4067
  },
4002
4068
  });
4003
4069
  // #2909 — the adopt, for a type whose push path adopts by key. `entry` is
@@ -4807,6 +4873,55 @@ selects = () => true) {
4807
4873
  : { ok: false };
4808
4874
  return { scriptEntities, count: written, presence, listOk: true, serverKeys };
4809
4875
  }
4876
+ /**
4877
+ * The manifest row `config push` records for a function it just pushed
4878
+ * (#3317), and whether the server had already moved past that push.
4879
+ *
4880
+ * The header is refetched AFTER the version push, so the row's `modifiedAt`
4881
+ * is the live timestamp — but the version pointer and the envelope are the
4882
+ * push's own: the manifest names what THIS run activated, not whatever the
4883
+ * refetch happened to find. A concurrent activation landing between the push
4884
+ * and the refetch is exactly the case those two must not be conflated in:
4885
+ * recording the refetched pointer would make the other operator's version the
4886
+ * baseline the local file is then read as a plain edit of, and the next push
4887
+ * would roll it back without `--force`. Recording the pushed pointer beside
4888
+ * the live timestamp is what `decidePushForConfig` reads as "the pointer
4889
+ * moved" — a refusal, until the operator pulls or forces.
4890
+ *
4891
+ * `movedTo` names the version the refetched header points at when it is not
4892
+ * the pushed one, so the caller can say so at the moment it is known rather
4893
+ * than leaving it for the next push to report.
4894
+ */
4895
+ export function pushedFunctionBaseline(input) {
4896
+ const { refreshed, pushedVersion } = input;
4897
+ const pushedConfigId = pushedVersion?.configId !== undefined && pushedVersion?.configId !== null
4898
+ ? String(pushedVersion.configId)
4899
+ : undefined;
4900
+ const liveConfigId = refreshed?.activeConfigId !== undefined && refreshed?.activeConfigId !== null
4901
+ ? String(refreshed.activeConfigId)
4902
+ : undefined;
4903
+ const movedTo = pushedConfigId !== undefined &&
4904
+ liveConfigId !== undefined &&
4905
+ liveConfigId !== pushedConfigId
4906
+ ? liveConfigId
4907
+ : null;
4908
+ return {
4909
+ entity: {
4910
+ id: input.functionId,
4911
+ modifiedAt: refreshed?.modifiedAt ||
4912
+ input.existingModifiedAt ||
4913
+ new Date().toISOString(),
4914
+ contentHash: input.contentHash,
4915
+ semanticHash: refreshed
4916
+ ? hashRemoteConfigForDiff(input.spec, refreshed, input.maps, pushedVersion ?? undefined)
4917
+ : undefined,
4918
+ envelopeHash: input.envelopeHash,
4919
+ activeConfigId: pushedConfigId,
4920
+ sources: input.sources,
4921
+ },
4922
+ movedTo,
4923
+ };
4924
+ }
4810
4925
  /**
4811
4926
  * Pull server-side `ServerFunction` rows into `functions/` (#3179).
4812
4927
  *
@@ -4864,7 +4979,10 @@ export async function pullFunctions(client, appId, configDir, logger = () => { }
4864
4979
  };
4865
4980
  if (!record.activeConfigId) {
4866
4981
  // No code pushed yet: the header is all there is to write.
4867
- writeFileSync(filePath, serializeFunction(record));
4982
+ // The logger carries #1446's one warning: a schema holding a `null`
4983
+ // anywhere cannot be a native table, so it is written as a JSON string
4984
+ // and the operator is told which key that happened to.
4985
+ writeFileSync(filePath, serializeFunction(record, undefined, logger));
4868
4986
  entity.contentHash = computeFileHash(filePath);
4869
4987
  entity.semanticHash = hashRemoteConfigForDiff(configDiffSpec("function"), record, { ruleSetIdToName: new Map(), ruleSetNameToId: new Map() });
4870
4988
  functionEntities[key] = entity;
@@ -6426,25 +6544,15 @@ export function registerConfigSyncCommands(sync) {
6426
6544
  // Init
6427
6545
  sync
6428
6546
  .command("init")
6547
+ .allowExcessArguments(false)
6429
6548
  .description("Initialize the config directory structure")
6430
- .argument("[app-id]", "App ID (uses current app if not specified)")
6431
- .option("--app <app-id>", "App ID")
6432
- .option("--dir <path>", "Config directory (overrides the auto-resolved per-env path)")
6433
- .action(async (appId, options) => {
6434
- // Refused before any tree read/write or request (#3153, F1): the tree
6435
- // path no longer partitions by app.
6436
- assertTreeApp(appId || options.app, "config init");
6437
- const resolvedAppId = resolveAppId(appId, options);
6438
- const configDir = resolveSyncDir({ userDir: options.dir });
6549
+ .action(async (options) => {
6550
+ const resolvedAppId = getCurrentAppId();
6551
+ const configDir = resolveSyncDir();
6439
6552
  // A baseline that was never renamed means an unmigrated tree; refused
6440
6553
  // here, before any request, snapshot or directory is made (#3153).
6441
6554
  assertSyncStateLayout(configDir);
6442
- if (isAutoResolvedSyncDir(options.dir)) {
6443
- info(`Using per-environment config directory: ${configDir}`);
6444
- const legacyHint = checkLegacySyncMigration(configDir);
6445
- if (legacyHint)
6446
- warn(legacyHint);
6447
- }
6555
+ info(`Using per-environment config directory: ${configDir}`);
6448
6556
  ensureDir(configDir);
6449
6557
  ensureDir(join(configDir, "integrations"));
6450
6558
  ensureDir(join(configDir, "webhooks"));
@@ -6473,10 +6581,8 @@ export function registerConfigSyncCommands(sync) {
6473
6581
  // Pull
6474
6582
  sync
6475
6583
  .command("pull")
6584
+ .allowExcessArguments(false)
6476
6585
  .description("Download configuration from server to TOML files")
6477
- .argument("[app-id]", "App ID (uses current app if not specified)")
6478
- .option("--app <app-id>", "App ID")
6479
- .option("--dir <path>", "Config directory (overrides the auto-resolved per-env path)")
6480
6586
  .option("--json", "Output as JSON instead of writing files")
6481
6587
  .option("--only <selectors>", "Fetch just these objects: <type>/<key>, app, or var/<key> (repeatable, or comma-separated). Everything else in the slot is left as it is.",
6482
6588
  // Collect rather than replace, the same way push does: commander keeps
@@ -6484,20 +6590,14 @@ export function registerConfigSyncCommands(sync) {
6484
6590
  // narrow the pull to one object and report success.
6485
6591
  (value, previous) => [...(previous ?? []), value], [])
6486
6592
  .option("--no-prune", "Keep local files for entities that are absent from the sync export (the next push will re-create them)")
6487
- .action(async (appId, options) => {
6488
- // Refused before any tree read/write or request (#3153, F1): the tree
6489
- // path no longer partitions by app.
6490
- assertTreeApp(appId || options.app, "config pull");
6491
- const resolvedAppId = resolveAppId(appId, options);
6492
- const configDir = resolveSyncDir({ userDir: options.dir });
6593
+ .action(async (options) => {
6594
+ const resolvedAppId = getCurrentAppId();
6595
+ const configDir = resolveSyncDir();
6493
6596
  // A baseline that was never renamed means an unmigrated tree; refused
6494
6597
  // here, before any request, snapshot or directory is made (#3153).
6495
6598
  assertSyncStateLayout(configDir);
6496
- if (isAutoResolvedSyncDir(options.dir) && !options.json) {
6599
+ if (!options.json) {
6497
6600
  info(`Using per-environment config directory: ${configDir}`);
6498
- const legacyHint = checkLegacySyncMigration(configDir);
6499
- if (legacyHint)
6500
- warn(legacyHint);
6501
6601
  }
6502
6602
  // Issue #2760: pull writes the renamed directory and leaves the old one
6503
6603
  // untouched beside it, which reads as a successful pull that lost the
@@ -6697,10 +6797,7 @@ export function registerConfigSyncCommands(sync) {
6697
6797
  // perform it without a recoverable backup. No-op on a fresh/empty
6698
6798
  // sync dir (nothing to back up). Prune to 28 days after success.
6699
6799
  try {
6700
- const snapshotsRoot = resolveSnapshotsRoot({
6701
- appId: resolvedAppId,
6702
- userDir: options.dir,
6703
- });
6800
+ const snapshotsRoot = resolveSnapshotsRoot({ appId: resolvedAppId });
6704
6801
  const snapshot = createSnapshot(configDir, snapshotsRoot);
6705
6802
  if (snapshot) {
6706
6803
  info(` Snapshot: ${snapshot.path}`);
@@ -6711,7 +6808,7 @@ export function registerConfigSyncCommands(sync) {
6711
6808
  error(`Failed to create a pre-pull snapshot: ${snapErr?.message ?? snapErr}`);
6712
6809
  error("Aborting pull before any local file is modified. " +
6713
6810
  "Resolve the snapshot error (e.g. disk space / permissions) and retry, " +
6714
- "or pass a writable --dir.");
6811
+ "and that the project's .primitive/ is writable.");
6715
6812
  process.exit(1);
6716
6813
  }
6717
6814
  // Ensure directories exist
@@ -7284,12 +7381,17 @@ export function registerConfigSyncCommands(sync) {
7284
7381
  // they are not in the envelope, and a fresh clone must end up with the
7285
7382
  // same three files a push would have written. Written here, after the
7286
7383
  // database types, so the declaration describes what this pull wrote.
7287
- {
7384
+ try {
7288
7385
  const artifacts = writeFunctionTypeArtifacts(configDir);
7289
7386
  if (artifacts.length > 0) {
7290
7387
  info(` Wrote function types: ${artifacts.join(", ")}`);
7291
7388
  }
7292
7389
  }
7390
+ catch (err) {
7391
+ if (!(err instanceof FunctionSchemaCodegenError))
7392
+ throw err;
7393
+ warn(` Function types not generated: ${err.message}`);
7394
+ }
7293
7395
  // Write rule sets
7294
7396
  const ruleSetEntities = {};
7295
7397
  for (const ruleSet of ruleSets) {
@@ -7768,10 +7870,8 @@ export function registerConfigSyncCommands(sync) {
7768
7870
  // Push
7769
7871
  sync
7770
7872
  .command("push")
7873
+ .allowExcessArguments(false)
7771
7874
  .description("Upload local TOML configuration to server")
7772
- .argument("[app-id]", "App ID (uses current app if not specified)")
7773
- .option("--app <app-id>", "App ID")
7774
- .option("--dir <path>", "Config directory (overrides the auto-resolved per-env path)")
7775
7875
  .option("--dry-run", "Show what would be changed without applying")
7776
7876
  .option("--force", "Overwrite remote even if modified since last pull")
7777
7877
  .option("--only <selectors>", "Apply just these objects: <type>/<key>, app, or var/<key> (repeatable, or comma-separated). Everything else about push is unchanged.",
@@ -7782,21 +7882,35 @@ export function registerConfigSyncCommands(sync) {
7782
7882
  .option("--prune", "Delete remote entities whose local TOML was removed since the last sync (issue #1655)")
7783
7883
  .option("-y, --yes", "Skip the interactive confirmation before pruning (for CI / automation)")
7784
7884
  .option("--accept-warnings", "Commit schema diffs that have operations with dynamic refs (issue #666 SCHEMA_HAS_UNCHECKABLE_OPS escape hatch)")
7785
- .action(async (appId, options) => {
7786
- // Refused before any tree read/write or request (#3153, F1): the tree
7787
- // path no longer partitions by app.
7788
- assertTreeApp(appId || options.app, "config push");
7789
- const resolvedAppId = resolveAppId(appId, options);
7790
- const configDir = resolveSyncDir({ userDir: options.dir });
7885
+ // #3320 — what this command actually guarantees, said where it is run.
7886
+ // Two stages, and only the first is all-or-nothing.
7887
+ .addHelpText("after", `
7888
+ What a push guarantees (two stages):
7889
+
7890
+ Preflight — the local tree only. Unknown keys and declared types, workflow
7891
+ lints, function grant grammar, a function's \`entry\`, the function trigger
7892
+ rules, and a webhook trigger's signing-secret requirement and reference
7893
+ shape. Any error among them aborts the whole run and nothing is applied.
7894
+ Those are the server's own rules, run against your files.
7895
+
7896
+ Apply — incremental and convergent, and there is no rollback. A rejection
7897
+ may stop the remaining work depending on the type; whatever was already
7898
+ applied stays applied; the exit status is non-zero; re-running converges.
7899
+ A refused FUNCTION is recorded and the loop continues, so the rest of the
7900
+ tree still lands.
7901
+
7902
+ Not preflighted, because the file cannot answer it: whether a referenced
7903
+ secret exists, whether a scheme's config is valid under this environment's
7904
+ policy, per-app caps, key collisions. Those surface during apply.
7905
+ `)
7906
+ .option("--no-typecheck", "Ship function code without typechecking it against the declarations this push generates (issue #3331)")
7907
+ .action(async (options) => {
7908
+ const resolvedAppId = getCurrentAppId();
7909
+ const configDir = resolveSyncDir();
7791
7910
  // A baseline that was never renamed means an unmigrated tree; refused
7792
7911
  // here, before any request, snapshot or directory is made (#3153).
7793
7912
  assertSyncStateLayout(configDir);
7794
- if (isAutoResolvedSyncDir(options.dir)) {
7795
- info(`Using per-environment config directory: ${configDir}`);
7796
- const legacyHint = checkLegacySyncMigration(configDir);
7797
- if (legacyHint)
7798
- warn(legacyHint);
7799
- }
7913
+ info(`Using per-environment config directory: ${configDir}`);
7800
7914
  // Issue #2760. An un-renamed checkout's `database-types/` is ignored, so
7801
7915
  // an ordinary push applies nothing from it — worth a warning. `--prune`
7802
7916
  // is the destructive case: it flags every prior-state key whose local
@@ -8486,6 +8600,22 @@ export function registerConfigSyncCommands(sync) {
8486
8600
  })) {
8487
8601
  preflightValidationErrors.push(` ${message}`);
8488
8602
  }
8603
+ // #3320 — and everything else the file decides on its own: the
8604
+ // `entry`, the mode the two spellings resolve to, the trigger
8605
+ // rules, and a webhook trigger's signing-secret requirement and
8606
+ // reference shape. These used to be raised inside the apply loop,
8607
+ // after webhooks, integrations, vars and database types had
8608
+ // already been written — the partial tree this issue is about.
8609
+ // Bounded by the same `selectFiles` above, so `--only` keeps its
8610
+ // posture (#2645).
8611
+ const functionKey = tomlData?.function?.key || basename(file, ".toml");
8612
+ for (const message of functionTomlPreflightErrors({
8613
+ filePath,
8614
+ functionKey,
8615
+ tomlData,
8616
+ })) {
8617
+ preflightValidationErrors.push(` ${filePath}: ${message}`);
8618
+ }
8489
8619
  }
8490
8620
  }
8491
8621
  }
@@ -12946,18 +13076,44 @@ export function registerConfigSyncCommands(sync) {
12946
13076
  // TREE rather than from the server: the types describe the schemas
12947
13077
  // this push is applying. Regenerated every time and never part of the
12948
13078
  // envelope — `config pull` recreates them the same way.
12949
- if (!options.dryRun) {
12950
- const artifacts = writeFunctionTypeArtifacts(configDir);
12951
- if (artifacts.length > 0) {
12952
- info(` Wrote function types: ${artifacts.join(", ")}`);
13079
+ // #3281 — a malformed schema in a function file cannot be typed;
13080
+ // the file's own push below refuses it by name, so the generators
13081
+ // warn here and the rest of the tree still lands.
13082
+ try {
13083
+ if (!options.dryRun) {
13084
+ const artifacts = writeFunctionTypeArtifacts(configDir);
13085
+ if (artifacts.length > 0) {
13086
+ info(` Wrote function types: ${artifacts.join(", ")}`);
13087
+ }
13088
+ }
13089
+ // A tsconfig push will not rewrite (comments are the author's) or
13090
+ // cannot read is still a tsconfig no compiler loads the handle
13091
+ // through. Say so rather than report success over it (CR3182-003).
13092
+ for (const problem of functionTypeWiringProblems(configDir)) {
13093
+ warn(` functions/tsconfig.json: ${problem}`);
12953
13094
  }
12954
13095
  }
12955
- // A tsconfig push will not rewrite (comments are the author's) or
12956
- // cannot read is still a tsconfig no compiler loads the handle
12957
- // through. Say so rather than report success over it (CR3182-003).
12958
- for (const problem of functionTypeWiringProblems(configDir)) {
12959
- warn(` functions/tsconfig.json: ${problem}`);
13096
+ catch (err) {
13097
+ if (!(err instanceof FunctionSchemaCodegenError))
13098
+ throw err;
13099
+ warn(` Function types not generated: ${err.message}`);
12960
13100
  }
13101
+ // #3331 — the compiler, over the declarations written (or, under
13102
+ // `--dry-run`, planned) just above. Built once for the tree and
13103
+ // consulted per function, so one broken file refuses itself and
13104
+ // nothing else; `--no-typecheck` is the escape hatch, and an
13105
+ // unavailable compiler warns rather than failing the push.
13106
+ let typecheckSetup = null;
13107
+ const functionTypechecker = () => {
13108
+ if (options.typecheck === false)
13109
+ return null;
13110
+ if (typecheckSetup === null) {
13111
+ typecheckSetup = createFunctionTypechecker(configDir);
13112
+ for (const warning of typecheckSetup.warnings)
13113
+ warn(` ${warning}`);
13114
+ }
13115
+ return typecheckSetup.checker;
13116
+ };
12961
13117
  const files = selectFiles("function", functionsPushDir, readdirSync(functionsPushDir).filter((f) => f.endsWith(".toml")));
12962
13118
  const functionSpec = configDiffSpec("function");
12963
13119
  const liveFunctions = new Map();
@@ -13010,8 +13166,26 @@ export function registerConfigSyncCommands(sync) {
13010
13166
  });
13011
13167
  continue;
13012
13168
  }
13169
+ // #3331 — and then the types. esbuild erases them, so a build that
13170
+ // succeeded says nothing about whether the code holds against the
13171
+ // declarations this same push generated: `ctx.user` going nullable
13172
+ // (#3279) bundles cleanly and throws on the request path. Refused
13173
+ // here, against THIS function's own sources, before anything is
13174
+ // sent for it and whether or not this is a dry run.
13175
+ const typechecker = functionTypechecker();
13176
+ if (typechecker) {
13177
+ const failure = typechecker.check(key, push.sources.map((source) => source.path));
13178
+ if (failure) {
13179
+ applyFailures.push({
13180
+ type: "function",
13181
+ key,
13182
+ message: failure.message,
13183
+ });
13184
+ continue;
13185
+ }
13186
+ }
13013
13187
  // The active version, needed by BOTH axes: the header comparison
13014
- // reads `entry`/`durable`/`limits` off it, and the code axis
13188
+ // reads `entry`/`mode`/`limits` off it, and the code axis
13015
13189
  // compares its `envelopeHash`.
13016
13190
  let activeVersion = null;
13017
13191
  let versionOk = functionsLiveOk;
@@ -13042,6 +13216,15 @@ export function registerConfigSyncCommands(sync) {
13042
13216
  entry: existingEntry,
13043
13217
  localFileHash: computeFileHash(filePath),
13044
13218
  force: options.force,
13219
+ // #3317 — the header row's `modifiedAt` covers everything
13220
+ // compared here: its own fields, and the `activeConfigId`
13221
+ // pointer whose repointing is the only way the immutable
13222
+ // version behind the extras can change. So an unmoved
13223
+ // timestamp proves the server stood still, and a baseline hash
13224
+ // that disagrees is a stale baseline — the reporter's CONFLICT
13225
+ // over two identical timestamps, which is also what every
13226
+ // manifest written before this issue's schema fix would hit.
13227
+ serverModifiedAtCoversProjection: true,
13045
13228
  // The create leg adopts by key on the server's 409 (#1006), so
13046
13229
  // an untracked function the server already holds is that same
13047
13230
  // adopt reached one step earlier.
@@ -13176,19 +13359,27 @@ export function registerConfigSyncCommands(sync) {
13176
13359
  // Non-fatal: the baseline falls back to what the listing said.
13177
13360
  // The next push reconciles it.
13178
13361
  }
13179
- syncState.entities.functions[key] = {
13180
- id: String(functionId),
13181
- modifiedAt: refreshed?.modifiedAt ||
13182
- existingEntry?.modifiedAt ||
13183
- new Date().toISOString(),
13362
+ // #3317 — the pointer recorded is the one THIS push activated,
13363
+ // whatever the refetch found: a concurrent activation in the
13364
+ // window between the two is a server move the next push must
13365
+ // refuse over, not a baseline it silently rolls back.
13366
+ const baseline = pushedFunctionBaseline({
13367
+ functionId: String(functionId),
13368
+ refreshed,
13369
+ existingModifiedAt: existingEntry?.modifiedAt,
13184
13370
  contentHash: computeFileHash(filePath),
13185
- semanticHash: refreshed
13186
- ? hashRemoteConfigForDiff(functionSpec, refreshed, pushDiffMaps, pushedVersion ?? undefined)
13187
- : undefined,
13371
+ pushedVersion,
13188
13372
  envelopeHash: push.envelopeHash,
13189
- activeConfigId: pushedVersion?.configId,
13190
13373
  sources: push.sources.map((src) => src.path),
13191
- };
13374
+ spec: functionSpec,
13375
+ maps: pushDiffMaps,
13376
+ });
13377
+ if (baseline.movedTo) {
13378
+ warn(` Function ${key}: the server's active version moved to ${baseline.movedTo} ` +
13379
+ `while version ${pushedVersion?.configId} was being recorded; ` +
13380
+ "the next push will report the drift (`config pull` adopts it, `--force` overrides it).");
13381
+ }
13382
+ syncState.entities.functions[key] = baseline.entity;
13192
13383
  }
13193
13384
  }
13194
13385
  }
@@ -14507,27 +14698,17 @@ export function registerConfigSyncCommands(sync) {
14507
14698
  // Diff
14508
14699
  sync
14509
14700
  .command("diff")
14701
+ .allowExcessArguments(false)
14510
14702
  .description("Show differences between local and remote configuration")
14511
- .argument("[app-id]", "App ID (uses current app if not specified)")
14512
- .option("--app <app-id>", "App ID")
14513
- .option("--dir <path>", "Config directory (overrides the auto-resolved per-env path)")
14514
14703
  .option("--only <selectors>", "Compare just these objects: <type>/<key>, app, or var/<key> (repeatable, or comma-separated).", (value, previous) => [...(previous ?? []), value], [])
14515
- .option("--json", "Output as JSON (app.toml field diff, with --only app)")
14516
- .action(async (appId, options) => {
14517
- // Refused before any tree read/write or request (#3153, F1): the tree
14518
- // path no longer partitions by app.
14519
- assertTreeApp(appId || options.app, "config diff");
14520
- const resolvedAppId = resolveAppId(appId, options);
14521
- const configDir = resolveSyncDir({ userDir: options.dir });
14704
+ .option("--json", "Output as JSON: the app.toml field diff with --only app, the compared rows otherwise")
14705
+ .action(async (options) => {
14706
+ const resolvedAppId = getCurrentAppId();
14707
+ const configDir = resolveSyncDir();
14522
14708
  // A baseline that was never renamed means an unmigrated tree; refused
14523
14709
  // here, before any request, snapshot or directory is made (#3153).
14524
14710
  assertSyncStateLayout(configDir);
14525
- if (isAutoResolvedSyncDir(options.dir)) {
14526
- info(`Using per-environment config directory: ${configDir}`);
14527
- const legacyHint = checkLegacySyncMigration(configDir);
14528
- if (legacyHint)
14529
- warn(legacyHint);
14530
- }
14711
+ info(`Using per-environment config directory: ${configDir}`);
14531
14712
  // Issue #2760: diff compares the renamed directory, so a legacy one is
14532
14713
  // simply invisible — an empty diff would otherwise read as "in sync".
14533
14714
  const legacyDbTypes = checkLegacyDatabaseTypesDir(configDir);
@@ -15205,7 +15386,7 @@ export function registerConfigSyncCommands(sync) {
15205
15386
  }
15206
15387
  let status = "exists";
15207
15388
  let hint;
15208
- // The active version carries `entry`, `durable` and `limits`, and its
15389
+ // The active version carries `entry`, `mode` and `limits`, and its
15209
15390
  // `envelopeHash` is the code axis.
15210
15391
  const versionOutcome = await attemptFetch(async () => {
15211
15392
  if (!remote.activeConfigId)
@@ -15724,7 +15905,16 @@ export function registerConfigSyncCommands(sync) {
15724
15905
  // the same preflight push runs decides these rows, and its message —
15725
15906
  // verbatim, so the two commands read identically — replaces whatever
15726
15907
  // the comparison had to say about the file.
15727
- for (const rejected of collectConfigFileValidationErrors(configDir)) {
15908
+ // #3320 — the function checks join them. Same posture, same messages:
15909
+ // a function file whose `entry`, mode, triggers or webhook
15910
+ // signing-secret the push preflight refuses is not a row this command
15911
+ // calls Synced, Modified or "local only — new".
15912
+ // Merged first: a file both collectors reject keeps every message push
15913
+ // prints, rather than one collector's hint overwriting the other's.
15914
+ for (const rejected of mergeConfigFileValidationErrors([
15915
+ ...collectConfigFileValidationErrors(configDir),
15916
+ ...collectFunctionTomlValidationErrors(configDir),
15917
+ ])) {
15728
15918
  const row = differences.find((d) => d.type === rejected.type && d.key === rejected.key);
15729
15919
  const hint = rejected.messages.join("; ");
15730
15920
  if (row) {
@@ -15755,6 +15945,22 @@ export function registerConfigSyncCommands(sync) {
15755
15945
  ? selectionIncludesApp(diffSelection)
15756
15946
  : selectionIncludesKey(diffSelection, row.type, row.key));
15757
15947
  }
15948
+ // #3320 — `--json` over the whole comparison, not just `--only app`.
15949
+ // The Invalid rows this issue adds have to be readable by the thing
15950
+ // that reads a diff programmatically (an editor, a CI job), and the
15951
+ // flag already existed for the app-settings case; a run that asked for
15952
+ // JSON and got a human table was answering a different question.
15953
+ // Emitted after the `--only` filter so a scoped run reports its scope.
15954
+ if (options.json) {
15955
+ console.log(JSON.stringify({
15956
+ appId: resolvedAppId,
15957
+ configDir,
15958
+ differences,
15959
+ testCases: testCaseDiffs,
15960
+ notCompared,
15961
+ }, null, 2));
15962
+ return;
15963
+ }
15758
15964
  divider();
15759
15965
  const localOnly = differences.filter((d) => d.status === LOCAL_ONLY_NEW);
15760
15966
  const localOnlyAbsent = differences.filter((d) => d.status === LOCAL_ONLY_ABSENT);
@@ -15887,7 +16093,23 @@ export function registerConfigSyncCommands(sync) {
15887
16093
  // change, so `config diff` is where their freshness is answered: an
15888
16094
  // editor or a CI job asks the same command it already asks "what would
15889
16095
  // push do?", and gets these named alongside everything else.
15890
- const staleFunctionTypes = staleFunctionTypeArtifacts(configDir);
16096
+ let staleFunctionTypes = [];
16097
+ let wiringProblems = [];
16098
+ try {
16099
+ staleFunctionTypes = staleFunctionTypeArtifacts(configDir);
16100
+ // Freshness is not the only way the handle stops working: a tsconfig
16101
+ // push will not rewrite can be perfectly current and still load none
16102
+ // of it. `config diff` is where CI asks, so it answers both
16103
+ // (CR3182-003).
16104
+ wiringProblems = functionTypeWiringProblems(configDir);
16105
+ }
16106
+ catch (err) {
16107
+ if (!(err instanceof FunctionSchemaCodegenError))
16108
+ throw err;
16109
+ // #3281 — a function file whose schema cannot be typed is reported
16110
+ // like a wiring problem: the generators cannot answer for the tree.
16111
+ wiringProblems = [err.message];
16112
+ }
15891
16113
  if (staleFunctionTypes.length > 0) {
15892
16114
  console.log();
15893
16115
  info("Function types (regenerated by `config push`):");
@@ -15895,10 +16117,6 @@ export function registerConfigSyncCommands(sync) {
15895
16117
  console.log(` ${chalk.yellow("~")} ${relative}`);
15896
16118
  }
15897
16119
  }
15898
- // Freshness is not the only way the handle stops working: a tsconfig
15899
- // push will not rewrite can be perfectly current and still load none of
15900
- // it. `config diff` is where CI asks, so it answers both (CR3182-003).
15901
- const wiringProblems = functionTypeWiringProblems(configDir);
15902
16120
  if (wiringProblems.length > 0) {
15903
16121
  console.log();
15904
16122
  info("Function types (compiler wiring `config push` cannot repair):");
@@ -15943,28 +16161,21 @@ export function registerConfigSyncCommands(sync) {
15943
16161
  // containing null) fall back to JSON-string per field with a log message.
15944
16162
  sync
15945
16163
  .command("migrate-toml")
16164
+ .allowExcessArguments(false)
15946
16165
  .description("Rewrite database-type and workflow TOML files to native form " +
15947
16166
  "([operations.definition] / [[operations.params]] for database types; " +
15948
16167
  "[workflow.inputSchema] / [workflow.outputSchema] for workflows)")
15949
- .argument("[app-id]", "App ID (uses current app if not specified)")
15950
- .option("--app <app-id>", "App ID")
15951
- .option("--dir <path>", "Config directory (overrides the auto-resolved per-env path)")
15952
16168
  .option("--dry-run", "Show what would change without writing files")
15953
- .action(async (appId, options) => {
15954
- // Resolve appId so we land in the per-env sync dir even when the user
15955
- // didn't pass `--dir`. The app ID itself isn't used for any API calls
15956
- // — `migrate-toml` is a purely local rewrite.
15957
- // Refused before any tree read/write or request (#3153, F1): the tree
15958
- // path no longer partitions by app.
15959
- assertTreeApp(appId || options.app, "config migrate-toml");
15960
- const resolvedAppId = resolveAppId(appId, options);
15961
- const configDir = resolveSyncDir({ userDir: options.dir });
16169
+ .action(async (options) => {
16170
+ // The app ID is not used for any API call — `migrate-toml` is a purely
16171
+ // local rewrite — but the environment that names it is what names the
16172
+ // tree being rewritten.
16173
+ const resolvedAppId = getCurrentAppId();
16174
+ const configDir = resolveSyncDir();
15962
16175
  // A baseline that was never renamed means an unmigrated tree; refused
15963
16176
  // here, before any request, snapshot or directory is made (#3153).
15964
16177
  assertSyncStateLayout(configDir);
15965
- if (isAutoResolvedSyncDir(options.dir)) {
15966
- info(`Using per-environment config directory: ${configDir}`);
15967
- }
16178
+ info(`Using per-environment config directory: ${configDir}`);
15968
16179
  if (!existsSync(configDir)) {
15969
16180
  error(`Config directory not found: ${configDir}`);
15970
16181
  process.exit(1);
@@ -16114,27 +16325,19 @@ export function registerConfigSyncCommands(sync) {
16114
16325
  // proven), so re-running push/pull is unaffected.
16115
16326
  sync
16116
16327
  .command("migrate-v2")
16328
+ .allowExcessArguments(false)
16117
16329
  .description("Convert legacy per-type sync files (prompts/, integrations/, workflows/, transforms/) into the v2 blocks/<type>/<key>/block.toml layout")
16118
- .argument("[app-id]", "App ID (uses current app if not specified)")
16119
- .option("--app <app-id>", "App ID")
16120
- .option("--dir <path>", "Config directory (overrides the auto-resolved per-env path)")
16121
16330
  .option("--dry-run", "Show what would change without writing files")
16122
16331
  .option("--force", "Overwrite existing block.toml files instead of skipping them")
16123
- .action(async (appId, options) => {
16124
- // Resolve appId so we land in the per-env sync dir even when the user
16125
- // didn't pass `--dir`. The app ID itself isn't used for any API calls —
16126
- // migrate-v2 is a purely local rewrite (like migrate-toml).
16127
- // Refused before any tree read/write or request (#3153, F1): the tree
16128
- // path no longer partitions by app.
16129
- assertTreeApp(appId || options.app, "config migrate-v2");
16130
- const resolvedAppId = resolveAppId(appId, options);
16131
- const configDir = resolveSyncDir({ userDir: options.dir });
16332
+ .action(async (options) => {
16333
+ // As with migrate-toml: a purely local rewrite, whose tree is the
16334
+ // resolved environment's.
16335
+ const resolvedAppId = getCurrentAppId();
16336
+ const configDir = resolveSyncDir();
16132
16337
  // A baseline that was never renamed means an unmigrated tree; refused
16133
16338
  // here, before any request, snapshot or directory is made (#3153).
16134
16339
  assertSyncStateLayout(configDir);
16135
- if (isAutoResolvedSyncDir(options.dir)) {
16136
- info(`Using per-environment config directory: ${configDir}`);
16137
- }
16340
+ info(`Using per-environment config directory: ${configDir}`);
16138
16341
  if (!existsSync(configDir)) {
16139
16342
  error(`Config directory not found: ${configDir}`);
16140
16343
  process.exit(1);
@@ -16173,29 +16376,19 @@ export function registerConfigSyncCommands(sync) {
16173
16376
  // Revert — restore a pre-pull snapshot (issue #578, Phase 1).
16174
16377
  sync
16175
16378
  .command("revert")
16379
+ .allowExcessArguments(false)
16176
16380
  .description("Restore the config directory from a snapshot taken before a previous pull")
16177
- .argument("[app-id]", "App ID (uses current app if not specified)")
16178
- .option("--app <app-id>", "App ID")
16179
- .option("--dir <path>", "Config directory (overrides the auto-resolved per-env path)")
16180
16381
  .option("--snapshot <id>", "Snapshot id (timestamp dirname or a unique >=8-char prefix)")
16181
16382
  .option("--list", "List available snapshots without restoring")
16182
16383
  .option("-y, --yes", "Skip the confirmation prompt")
16183
- .action(async (appId, options) => {
16184
- // Refused before any tree read/write or request (#3153, F1): the tree
16185
- // path no longer partitions by app.
16186
- assertTreeApp(appId || options.app, "config revert");
16187
- const resolvedAppId = resolveAppId(appId, options);
16188
- const configDir = resolveSyncDir({ userDir: options.dir });
16384
+ .action(async (options) => {
16385
+ const resolvedAppId = getCurrentAppId();
16386
+ const configDir = resolveSyncDir();
16189
16387
  // A baseline that was never renamed means an unmigrated tree; refused
16190
16388
  // here, before any request, snapshot or directory is made (#3153).
16191
16389
  assertSyncStateLayout(configDir);
16192
- const snapshotsRoot = resolveSnapshotsRoot({
16193
- appId: resolvedAppId,
16194
- userDir: options.dir,
16195
- });
16196
- if (isAutoResolvedSyncDir(options.dir)) {
16197
- info(`Using per-environment config directory: ${configDir}`);
16198
- }
16390
+ const snapshotsRoot = resolveSnapshotsRoot({ appId: resolvedAppId });
16391
+ info(`Using per-environment config directory: ${configDir}`);
16199
16392
  const snapshots = listSnapshots(snapshotsRoot);
16200
16393
  // --list, or no snapshots at all: enumerate and stop.
16201
16394
  if (options.list || snapshots.length === 0) {
@@ -16255,9 +16448,7 @@ export function registerConfigSyncCommands(sync) {
16255
16448
  }
16256
16449
  }
16257
16450
  try {
16258
- // Under legacy `--dir`, snapshotsRoot lives inside configDir; preserve
16259
- // it across the full-tree swap so we don't wipe backup history.
16260
- restoreSnapshot(target.path, configDir, { preserveDir: snapshotsRoot });
16451
+ restoreSnapshot(target.path, configDir);
16261
16452
  }
16262
16453
  catch (err) {
16263
16454
  error(`Restore failed: ${err?.message ?? err}`);