@fjall/util 28.2.0 → 28.4.0

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.
@@ -9,7 +9,7 @@
9
9
  "package.json",
10
10
  "tsconfig.json"
11
11
  ],
12
- "hash": "a07238212f0e522feebb88afc1b3581bdb501b0f479775e7a2014c13d4a66cd4",
12
+ "hash": "58b95a45422cdf5c5a0375938cb6dd8a3401ac4afc0505a2b6e452bb4e14d04f",
13
13
  "files": {
14
14
  "src/async/concurrency.ts": "3c7ebe0222a720f9",
15
15
  "src/async/singleton.ts": "7bcc21b0fb920561",
@@ -22,8 +22,8 @@
22
22
  "src/aws/driftSuspects.ts": "5400dc05ebd6c977",
23
23
  "src/aws/errors.ts": "5addfa6bb2d1456a",
24
24
  "src/aws/failureAnalysisCategories.ts": "85ab1632d8069273",
25
- "src/aws/index.ts": "d74f645bf6ba312c",
26
- "src/aws/infraTags.ts": "6b1e65bf7119f32c",
25
+ "src/aws/index.ts": "ce3da231274defdf",
26
+ "src/aws/infraTags.ts": "ba9442248b8a0ef4",
27
27
  "src/aws/ipamTags.ts": "ac19ecd6c36b3bc2",
28
28
  "src/aws/maskFailureAnalysis.ts": "ae11250412cd6fd8",
29
29
  "src/aws/monitoringRole.ts": "70c8b5947dc9138d",
@@ -33,7 +33,7 @@
33
33
  "src/cdkToolchain.ts": "a6b996c60b87d644",
34
34
  "src/cfn/physicalNameProperties.ts": "35d3c6f28079c593",
35
35
  "src/clickhouse.ts": "655c74cdba292c29",
36
- "src/config.ts": "77011bd9d88e0129",
36
+ "src/config.ts": "6e15344c1d4a79fe",
37
37
  "src/configPaths.ts": "afcc2e4ceb6652a0",
38
38
  "src/constructMap.ts": "b81fc8e90015467f",
39
39
  "src/deploy/approvalTokenOutput.ts": "4be7b52825d88a37",
@@ -91,7 +91,7 @@
91
91
  "src/migration/clickhouseFrameworkUsers.ts": "4bd55a96d4fddff4",
92
92
  "src/migration/clickhouseSqlUsers.ts": "c916e148367715bb",
93
93
  "src/migration/compareSchemaVersion.ts": "1c9247e722ec5d5b",
94
- "src/migration/constants.ts": "ea1722b563e2875e",
94
+ "src/migration/constants.ts": "5c585a455ba56d95",
95
95
  "src/migration/controlPlaneActivity.ts": "c7ba33958f9fcbcc",
96
96
  "src/migration/index.ts": "dc711329d0060213",
97
97
  "src/migration/pickLatestClickHouseMigration.ts": "a5b4a00ba63522cb",
@@ -124,6 +124,8 @@
124
124
  "src/repo/scanLocalRepository.ts": "c7bc76201622a7a5",
125
125
  "src/repo/scanTypes.ts": "1fda805a7d8de93b",
126
126
  "src/resourceCategorisation.ts": "a140d1b3cb38be33",
127
+ "src/rootConfigMerge.ts": "e5085a5ae802647d",
128
+ "src/rootConfigSchema.ts": "5751173d999c0690",
127
129
  "src/scaffold/ensureGitignore.ts": "178a2c3b2c18bc83",
128
130
  "src/scaffold/gitignore.ts": "87c66797a4bd05f2",
129
131
  "src/secrets.ts": "50258d463e4e67c3",
@@ -131,9 +133,9 @@
131
133
  "src/staticSiteOutput.ts": "cc81ed32acc597f1",
132
134
  "src/targets.ts": "126280f0a5762e21",
133
135
  "../scripts/minify-dist.mjs": "6b2e4b0df8aec601",
134
- "package.json": "2905c8b5387c5578",
136
+ "package.json": "9a1647d40b0ac7f3",
135
137
  "tsconfig.json": "db759831ad95f396"
136
138
  },
137
- "outputHash": "da4ec1b532eae3b07df1e167e1452d29879f0a6ea03cca47f9472b8e2229b7e4",
138
- "outputFileCount": 247
139
+ "outputHash": "f6940cdf7b816e044f003e7997da832ca7eb3d23098cc20b8879221be9c2af9b",
140
+ "outputFileCount": 251
139
141
  }
package/dist/.minified CHANGED
@@ -1 +1 @@
1
- 123 files minified at 2026-09-01T23:53:35.097Z
1
+ 125 files minified at 2026-09-02T10:08:10.972Z
@@ -6,6 +6,6 @@ export { FAILURE_ANALYSIS_CATEGORIES, type FailureAnalysisCategory } from "./fai
6
6
  export { maskFailureAnalysis } from "./maskFailureAnalysis.js";
7
7
  export { classifyDriftSuspectEvents, findCurrentOperationStart, isDriftSuspectReason, isOperationOpeningEvent, type DriftSuspectEvent } from "./driftSuspects.js";
8
8
  export { IPAM_OPERATIONS_POOL_TAG_KEY, formatIpamPairTagValue } from "./ipamTags.js";
9
- export { SDK_PRE_EMPTY_TAG_KEY } from "./infraTags.js";
9
+ export { KMS_RETENTION_TAG_KEY, SDK_PRE_EMPTY_TAG_KEY } from "./infraTags.js";
10
10
  export { COST_ALLOCATION_TAGS, DEFAULT_COST_ALLOCATION_ENVIRONMENT, DEFAULT_COST_ALLOCATION_OWNER, ecrCreateRepositoryTags, rdsPreMigrationSnapshotTags, type AwsSdkTag, type EcrCreateTag } from "./costAllocationTags.js";
11
11
  export { ACCOUNT_MONITORING_ROLE_NAME } from "./monitoringRole.js";
package/dist/aws/index.js CHANGED
@@ -1 +1 @@
1
- import{AWSError as e,NoRolesFoundError as t,InvalidCredentialsError as s,SSOTokenExpiredError as E,MissingRegionError as i,ProfileNotFoundError as O,CommandError as a,isAWSError as A,isNoRolesFoundError as T,isSSOUnauthorizedError as _}from"./errors.js";import{STACK_NOT_FOUND_PATTERN as S,CDK_NO_STACKS_MATCH as p,isResourceEvent as N}from"./cloudformationTypes.js";import{countResourceProgress as m,formatElapsed as C}from"./deployProgress.js";import{CloudFormationFailureAnalyser as u}from"./CloudFormationFailureAnalyser.js";import{FAILURE_ANALYSIS_CATEGORIES as I}from"./failureAnalysisCategories.js";import{maskFailureAnalysis as x}from"./maskFailureAnalysis.js";import{classifyDriftSuspectEvents as F,findCurrentOperationStart as P,isDriftSuspectReason as g,isOperationOpeningEvent as M}from"./driftSuspects.js";import{IPAM_OPERATIONS_POOL_TAG_KEY as D,formatIpamPairTagValue as K}from"./ipamTags.js";import{SDK_PRE_EMPTY_TAG_KEY as G}from"./infraTags.js";import{COST_ALLOCATION_TAGS as y,DEFAULT_COST_ALLOCATION_ENVIRONMENT as Y,DEFAULT_COST_ALLOCATION_OWNER as W,ecrCreateRepositoryTags as h,rdsPreMigrationSnapshotTags as k}from"./costAllocationTags.js";import{ACCOUNT_MONITORING_ROLE_NAME as z}from"./monitoringRole.js";export{z as ACCOUNT_MONITORING_ROLE_NAME,e as AWSError,p as CDK_NO_STACKS_MATCH,y as COST_ALLOCATION_TAGS,u as CloudFormationFailureAnalyser,a as CommandError,Y as DEFAULT_COST_ALLOCATION_ENVIRONMENT,W as DEFAULT_COST_ALLOCATION_OWNER,I as FAILURE_ANALYSIS_CATEGORIES,D as IPAM_OPERATIONS_POOL_TAG_KEY,s as InvalidCredentialsError,i as MissingRegionError,t as NoRolesFoundError,O as ProfileNotFoundError,G as SDK_PRE_EMPTY_TAG_KEY,E as SSOTokenExpiredError,S as STACK_NOT_FOUND_PATTERN,F as classifyDriftSuspectEvents,m as countResourceProgress,h as ecrCreateRepositoryTags,P as findCurrentOperationStart,C as formatElapsed,K as formatIpamPairTagValue,A as isAWSError,g as isDriftSuspectReason,T as isNoRolesFoundError,M as isOperationOpeningEvent,N as isResourceEvent,_ as isSSOUnauthorizedError,x as maskFailureAnalysis,k as rdsPreMigrationSnapshotTags};
1
+ import{AWSError as e,NoRolesFoundError as t,InvalidCredentialsError as E,SSOTokenExpiredError as s,MissingRegionError as T,ProfileNotFoundError as _,CommandError as i,isAWSError as O,isNoRolesFoundError as A,isSSOUnauthorizedError as a}from"./errors.js";import{STACK_NOT_FOUND_PATTERN as S,CDK_NO_STACKS_MATCH as N,isResourceEvent as p}from"./cloudformationTypes.js";import{countResourceProgress as m,formatElapsed as C}from"./deployProgress.js";import{CloudFormationFailureAnalyser as u}from"./CloudFormationFailureAnalyser.js";import{FAILURE_ANALYSIS_CATEGORIES as l}from"./failureAnalysisCategories.js";import{maskFailureAnalysis as x}from"./maskFailureAnalysis.js";import{classifyDriftSuspectEvents as F,findCurrentOperationStart as P,isDriftSuspectReason as M,isOperationOpeningEvent as g}from"./driftSuspects.js";import{IPAM_OPERATIONS_POOL_TAG_KEY as c,formatIpamPairTagValue as D}from"./ipamTags.js";import{KMS_RETENTION_TAG_KEY as U,SDK_PRE_EMPTY_TAG_KEY as Y}from"./infraTags.js";import{COST_ALLOCATION_TAGS as y,DEFAULT_COST_ALLOCATION_ENVIRONMENT as W,DEFAULT_COST_ALLOCATION_OWNER as h,ecrCreateRepositoryTags as k,rdsPreMigrationSnapshotTags as V}from"./costAllocationTags.js";import{ACCOUNT_MONITORING_ROLE_NAME as H}from"./monitoringRole.js";export{H as ACCOUNT_MONITORING_ROLE_NAME,e as AWSError,N as CDK_NO_STACKS_MATCH,y as COST_ALLOCATION_TAGS,u as CloudFormationFailureAnalyser,i as CommandError,W as DEFAULT_COST_ALLOCATION_ENVIRONMENT,h as DEFAULT_COST_ALLOCATION_OWNER,l as FAILURE_ANALYSIS_CATEGORIES,c as IPAM_OPERATIONS_POOL_TAG_KEY,E as InvalidCredentialsError,U as KMS_RETENTION_TAG_KEY,T as MissingRegionError,t as NoRolesFoundError,_ as ProfileNotFoundError,Y as SDK_PRE_EMPTY_TAG_KEY,s as SSOTokenExpiredError,S as STACK_NOT_FOUND_PATTERN,F as classifyDriftSuspectEvents,m as countResourceProgress,k as ecrCreateRepositoryTags,P as findCurrentOperationStart,C as formatElapsed,D as formatIpamPairTagValue,O as isAWSError,M as isDriftSuspectReason,A as isNoRolesFoundError,g as isOperationOpeningEvent,p as isResourceEvent,a as isSSOUnauthorizedError,x as maskFailureAnalysis,V as rdsPreMigrationSnapshotTags};
@@ -16,3 +16,19 @@
16
16
  * guards against casual renames.
17
17
  */
18
18
  export declare const SDK_PRE_EMPTY_TAG_KEY = "fjall:sdk-pre-empty";
19
+ /**
20
+ * The KMS retention marker tag, shared by producer and consumer:
21
+ *
22
+ * - Producer (`@fjall/components-infrastructure`): `CustomerManagedKey`
23
+ * (`secrets/kms.ts`) tags every key whose resolved removal policy is
24
+ * RETAIN, valued with the `protects` scope that justified the retention.
25
+ * - Consumer (webapp posture): the `KMS_KEY_ORPHAN` tier-policy row reads it
26
+ * to tell a key retained on purpose from one that was forgotten (tier-aware
27
+ * alert policy design 2026-09-01 §8 K4). The row waits until deployed keys
28
+ * carry the tag.
29
+ *
30
+ * The literal is deployed infrastructure state: a rename would strip every
31
+ * retained key of its marker until the next deploy. The pin test in
32
+ * `__tests__/infraTags.test.ts` guards against casual renames.
33
+ */
34
+ export declare const KMS_RETENTION_TAG_KEY = "fjall:kms:retention";
@@ -1 +1 @@
1
- const _="fjall:sdk-pre-empty";export{_ as SDK_PRE_EMPTY_TAG_KEY};
1
+ const t="fjall:sdk-pre-empty",_="fjall:kms:retention";export{_ as KMS_RETENTION_TAG_KEY,t as SDK_PRE_EMPTY_TAG_KEY};
package/dist/config.d.ts CHANGED
@@ -1,6 +1,8 @@
1
- import { z } from "zod";
2
1
  import { type Result } from "./docker/result.js";
3
2
  import { type AccountTier } from "./environments.js";
3
+ import { type DomainConfig, type OrganisationBinding, type RootConfig } from "./rootConfigSchema.js";
4
+ export { DomainConfigSchema, OrganisationBindingSchema, ROOT_CONFIG_KEYS, RootConfigReadSchema, RootConfigSchema, serialiseRootConfig, type DomainConfig, type OrganisationBinding, type RootConfig, type RootConfigRead } from "./rootConfigSchema.js";
5
+ export { mergeRootConfig, mergeRootConfigContent, readRootConfigForMerge, type RootConfigDiskState, type RootConfigMergeRead, type RootConfigMergeResult } from "./rootConfigMerge.js";
4
6
  export { LOCAL_CONFIG_DIRNAME, LOCAL_CONFIG_FILENAME, ROOT_CONFIG_FILENAME } from "./configPaths.js";
5
7
  /**
6
8
  * The Fjall root config filename. Single source of truth — the repo-root marker
@@ -138,115 +140,6 @@ export type SSOSession = {
138
140
  ssoRegion: string;
139
141
  ssoStartUrl: string;
140
142
  };
141
- /**
142
- * STRICT wire contract for one `domains[]` entry in fjall-config.json —
143
- * unknown keys are rejected, never silently carried. This is the write-path
144
- * schema shared with the webapp's domain-topology resolver; a scaffold or
145
- * saver emitting a key this schema rejects is exactly the drift it prevents.
146
- */
147
- export declare const DomainConfigSchema: z.ZodObject<{
148
- name: z.ZodString;
149
- type: z.ZodEnum<{
150
- apex: "apex";
151
- delegated: "delegated";
152
- }>;
153
- parentDomain: z.ZodOptional<z.ZodString>;
154
- account: z.ZodOptional<z.ZodString>;
155
- region: z.ZodOptional<z.ZodString>;
156
- }, z.core.$strict>;
157
- export type DomainConfig = z.infer<typeof DomainConfigSchema>;
158
- /**
159
- * STRICT wire contract for the `organisation` key — the project's binding to
160
- * ONE Fjall organisation.
161
- *
162
- * This is the ONE organisation fact that belongs in fjall-config.json, and it
163
- * is not a mirror of API state: it is a project ASSERTION that says which
164
- * organisation this source tree deploys into. Two writers, both recording
165
- * stated intent: `fjall create organisation` writes it at scaffold time (the
166
- * organisation being created IS the command's subject — the project is born
167
- * bound), and `fjall org bind` records or deliberately re-records it for
168
- * projects that predate birth binding or genuinely move. Everything else
169
- * about an organisation (accounts, regions, tiers) is mutable API state that
170
- * a committed copy would misreport, which is why it stays out of this file.
171
- *
172
- * `id` is the only field ever compared. `name` is display-only, so renaming an
173
- * organisation neither trips the guard nor dirties a checkout.
174
- */
175
- export declare const OrganisationBindingSchema: z.ZodObject<{
176
- id: z.ZodString;
177
- name: z.ZodOptional<z.ZodString>;
178
- }, z.core.$strict>;
179
- export type OrganisationBinding = z.infer<typeof OrganisationBindingSchema>;
180
- export declare const RootConfigSchema: z.ZodObject<{
181
- activeTarget: z.ZodOptional<z.ZodString>;
182
- domains: z.ZodOptional<z.ZodArray<z.ZodObject<{
183
- name: z.ZodString;
184
- type: z.ZodEnum<{
185
- apex: "apex";
186
- delegated: "delegated";
187
- }>;
188
- parentDomain: z.ZodOptional<z.ZodString>;
189
- account: z.ZodOptional<z.ZodString>;
190
- region: z.ZodOptional<z.ZodString>;
191
- }, z.core.$strict>>>;
192
- capacityIdentity: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodString, z.ZodObject<{
193
- legacyAnchor: z.ZodString;
194
- legacyOwnerId: z.ZodOptional<z.ZodString>;
195
- legacySgDescription: z.ZodOptional<z.ZodString>;
196
- }, z.core.$strict>>>>>;
197
- organisation: z.ZodOptional<z.ZodObject<{
198
- id: z.ZodString;
199
- name: z.ZodOptional<z.ZodString>;
200
- }, z.core.$strict>>;
201
- }, z.core.$strict>;
202
- export type RootConfig = z.infer<typeof RootConfigSchema>;
203
- /**
204
- * Tolerant READ schema for the deploy-time load path. Intentionally NOT
205
- * `.strict()`: a fjall-config.json written by a different fjall version, or a
206
- * legacy pre-config-split file, may carry top-level keys this version does not
207
- * know (`version`, `services`, `providerAccounts`, `generatorVersion`, …).
208
- * Stripping them and reading only the recognised keys keeps `fjall deploy` from
209
- * hard-failing on an otherwise-harmless older file. The strict RootConfigSchema
210
- * remains the contract for the WRITE/save path — never use this schema to
211
- * validate what you are about to write. Domain entries stay nested-strict
212
- * (DomainConfigSchema): tolerance applies to unknown TOP-LEVEL keys only.
213
- */
214
- export declare const RootConfigReadSchema: z.ZodObject<{
215
- activeTarget: z.ZodOptional<z.ZodString>;
216
- domains: z.ZodOptional<z.ZodArray<z.ZodObject<{
217
- name: z.ZodString;
218
- type: z.ZodEnum<{
219
- apex: "apex";
220
- delegated: "delegated";
221
- }>;
222
- parentDomain: z.ZodOptional<z.ZodString>;
223
- account: z.ZodOptional<z.ZodString>;
224
- region: z.ZodOptional<z.ZodString>;
225
- }, z.core.$strict>>>;
226
- capacityIdentity: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodString, z.ZodObject<{
227
- legacyAnchor: z.ZodString;
228
- legacyOwnerId: z.ZodOptional<z.ZodString>;
229
- legacySgDescription: z.ZodOptional<z.ZodString>;
230
- }, z.core.$strict>>>>>;
231
- organisation: z.ZodOptional<z.ZodObject<{
232
- id: z.ZodString;
233
- name: z.ZodOptional<z.ZodString>;
234
- }, z.core.$strict>>;
235
- }, z.core.$strip>;
236
- export type RootConfigRead = z.infer<typeof RootConfigReadSchema>;
237
- /**
238
- * Canonical serialiser for the root fjall-config.json - the single source of
239
- * truth for the file's on-disk shape. Both `Config.saveConfig` and the webapp
240
- * scaffold MUST route through this rather than hand-rolling the JSON, so the
241
- * two can never drift (a scaffold emitting a key the loader later rejects is
242
- * exactly the bug this prevents). The `RootConfig` input type keeps OUR keys
243
- * honest at compile time, but the emitted JSON is deliberately NOT
244
- * strict-validated: `mergeWithDisk` routes top-level keys another fjall
245
- * version wrote through the save verbatim (see its doc), so a merged save may
246
- * carry keys RootConfigSchema does not recognise. The round-trip test asserts
247
- * only that the emitted DEFAULT validates against RootConfigSchema.
248
- */
249
- export declare function serialiseRootConfig(config?: RootConfig): string;
250
143
  /**
251
144
  * Config class for loading and saving the root fjall-config.json.
252
145
  * Single config file at fjall/fjall-config.json (or fjall-config.json at cwd).
@@ -318,11 +211,9 @@ export declare class Config {
318
211
  /**
319
212
  * Session-intent merge: re-reads the on-disk config immediately before
320
213
  * writing and asserts ONLY what this session actually changed relative to
321
- * its load-time snapshot (see loadedSnapshot). An untouched key keeps the
322
- * disk's current value, the `domains` key merges PER ENTRY (keyed by name)
323
- * so concurrently-registered siblings survive, keys this version does not
324
- * recognise ride through verbatim, and keys explicitly cleared this
325
- * session are removed even when the disk copy still carries them.
214
+ * its load-time snapshot (see loadedSnapshot). The rules live in
215
+ * `mergeRootConfig` (`./rootConfigMerge.ts`), shared with the webapp
216
+ * scaffold so the file's two writers cannot drift apart.
326
217
  * An absent file (first-ever save) writes the in-memory state as-is; an
327
218
  * unreadable or invalid existing file REFUSES the save — the in-memory
328
219
  * state came through the tolerant stripping read, so writing it unmerged
@@ -330,20 +221,10 @@ export declare class Config {
330
221
  */
331
222
  private mergeWithDisk;
332
223
  /**
333
- * Did this session change `key` relative to its load-time snapshot?
334
- * JSON comparison is exact for today's scalar keys; a future nested
335
- * top-level key needs its own per-entry merge (as `domains` has).
336
- */
337
- private sessionChangedKey;
338
- /**
339
- * Per-entry merge of the `domains` key. Disk order is the base: an entry
340
- * this session changed is asserted, an entry this session removed (it was
341
- * in the load snapshot, it is gone from live state) drops out, and any
342
- * entry this session never touched keeps the DISK version - including
343
- * entries a concurrent process registered after this session loaded.
344
- * Session additions append after the disk entries.
224
+ * Filesystem half of the pre-save re-read: an absent file needs no merge, an
225
+ * unreadable one refuses the save, and the bytes of anything else are
226
+ * classified by `readRootConfigForMerge`.
345
227
  */
346
- private mergeDomains;
347
228
  private static readDiskConfigForMerge;
348
229
  static getConfigDirectory(startDir?: string): string | null;
349
230
  /**
package/dist/config.js CHANGED
@@ -1,2 +1,2 @@
1
- var F=Object.defineProperty;var p=(u,e)=>F(u,"name",{value:e,configurable:!0});import*as s from"fs";import*as g from"path";import{z as c}from"zod";import{CapacityIdentityConfigSchema as A}from"./capacityIdentity.js";import{failure as h,success as w}from"./docker/result.js";import{getErrorMessage as E}from"./errorUtils.js";import{logger as S}from"./logger.js";import{maskSensitiveOutput as C}from"./securityHelpers.js";import{ensureGitignoreEntries as I}from"./scaffold/ensureGitignore.js";import{FJALL_MANAGED_STATE_DIR_ENTRIES as R,FJALL_PROJECT_GITIGNORE_HEADER as b,LOCAL_CONFIG_GITIGNORE_ENTRY as x,LOCAL_STATE_GITIGNORE_MARKER as $}from"./scaffold/gitignore.js";import{LOCAL_CONFIG_DIRNAME as M,LOCAL_CONFIG_FILENAME as G,ROOT_CONFIG_FILENAME as T}from"./configPaths.js";import{LOCAL_CONFIG_DIRNAME as ge,LOCAL_CONFIG_FILENAME as ue,ROOT_CONFIG_FILENAME as he}from"./configPaths.js";const P=10,ee=["compliance","governance","none"],te=["enforced","off"],ne=["centralised","off"],oe=["account","draining","org"],ie="managementEvents",re="organisationManagementEvents",ae=["active","draining","removed"],se="FjallTrailBucketName",ce="FjallTrailKeyArn",le="OrganisationTrailBucketName",j=c.object({name:c.string(),type:c.enum(["apex","delegated"]),parentDomain:c.string().optional(),account:c.string().optional(),region:c.string().optional()}).strict(),L=c.object({id:c.string().min(1),name:c.string().min(1).optional()}).strict(),_=c.object({activeTarget:c.string().optional(),domains:c.array(j).optional(),capacityIdentity:A.optional(),organisation:L.optional()}).strict(),N=c.object({activeTarget:c.string().optional(),domains:c.array(j).optional(),capacityIdentity:A.optional(),organisation:L.optional()}),K=c.object({activeTarget:c.string().optional()});function J(u={}){return JSON.stringify(u,null,2)}p(J,"serialiseRootConfig");const k=_.keyof().options;function U(u,e,t){const n=e[t];n!==void 0&&(u[t]=n)}p(U,"copyDefinedKey");function D(u,e){if(e===void 0)return!1;const t=new Set([...Object.keys(u),...Object.keys(e)]);for(const n of t)if(u[n]!==e[n])return!1;return!0}p(D,"domainEntriesEqual");class f{static{p(this,"Config")}rootConfig;configPath=null;loadFailed=!1;parseDegraded=!1;loadedSnapshot={};searchStartDir;constructor(e,t){this.rootConfig=e??{},this.configPath=t??null}static findConfigDirectory(e){let t=e!==void 0&&e!==""?e:process.cwd();for(let n=0;n<P;n++){const o=g.join(t,"fjall"),r=g.join(o,T);if(s.existsSync(r))return o;const i=g.join(t,T);if(s.existsSync(i))return t;const l=g.dirname(t);if(l===t)break;t=l}return null}static loadConfigFile(e){try{return s.accessSync(e,s.constants.R_OK),s.readFileSync(e,{encoding:"utf8"})}catch(t){return S.warn("Config",`Config file at ${e} could not be read; using defaults`,{file:e,error:C(E(t))}),null}}static loadConfig(e){const t=f.findConfigDirectory(e);if(!t){const d=new f;return d.searchStartDir=e,d}const n=g.join(t,T),o=f.loadConfigFile(n);if(o===null){const d=new f(void 0,n);return d.searchStartDir=e,d.loadFailed=!0,d}let r,i=!1;if(o!==""){let d;try{d=JSON.parse(o)}catch(a){throw f.formatZodError(a,T)}const y=_.safeParse(d);if(y.success)r=y.data;else{const a=N.safeParse(d);r=a.success?a.data:{},i=!a.success,a.success?S.warn("Config","fjall-config.json contains keys this version does not recognise (only activeTarget, domains, capacityIdentity and organisation are read). They were most likely written by a newer fjall version \u2014 leave them in place; saves preserve them verbatim.",{file:n}):S.warn("Config","fjall-config.json has a malformed activeTarget, domains or capacityIdentity value, so its contents were ignored for this run \u2014 any capacity-identity pins it holds will NOT apply (a pinned stack deploys unpinned and is blocked by the rename gate). Fix the file by hand; do NOT regenerate it, which would discard the pins.",{file:n})}}const l=new f(r,n);return l.searchStartDir=e,l.parseDegraded=i,l.loadedSnapshot=structuredClone(l.rootConfig),l}static formatZodError(e,t){if(e instanceof c.ZodError&&e.issues.length>0){const r=e.issues.map(i=>`${i.path.join(".")}: ${i.message}`).join("; ");return new Error(`Failed to parse ${t}: ${r}`)}const o=(e instanceof Error?e.message:String(e)).replace(/\n/g," ").substring(0,500);return new Error(`Failed to parse ${t}: ${o}`)}saveConfig(){const e=g.join(this.resolveProjectDirectory()??g.join(this.searchStartDir??process.cwd(),"fjall"),T);if(this.loadFailed)return h(new Error(`Refusing to save ${e}: the file exists but could not be read when this config loaded, so saving would replace its contents with state that never included them. Fix the file permissions (e.g. chmod u+rw ${e}) and retry.`));const t=g.dirname(e);try{s.mkdirSync(t,{recursive:!0})}catch(l){return h(new Error(`Cannot create config directory ${t}: ${C(E(l))}`))}const n=f.assertWritable(e,t);if(!n.success)return n;const o=this.mergeWithDisk(e);if(!o.success)return o;const r=J(o.data),i=`${e}.tmp-${process.pid}`;try{s.writeFileSync(i,r,{mode:384}),s.renameSync(i,e)}catch(l){return h(new Error(`Failed to save ${e}: ${C(E(l))}`))}return this.configPath=e,w(void 0)}static assertWritable(e,t){if(s.existsSync(e))try{s.accessSync(e,s.constants.W_OK)}catch{return h(new Error(`Cannot save ${e}: the file is read-only. Make it writable (e.g. chmod u+w ${e}) and retry.`))}try{s.accessSync(t,s.constants.W_OK)}catch{return h(new Error(`Cannot save ${e}: the directory ${t} is not writable. Make it writable (e.g. chmod u+w ${t}) and retry.`))}return w(void 0)}mergeWithDisk(e){const t=f.readDiskConfigForMerge(e);if(t.kind==="unmergeable")return h(new Error(`Refusing to save ${e}: ${t.reason}. Writing now would keep only this session's state and drop everything else the file carries (including keys written by other fjall versions). Fix or remove the file and retry.`));if(t.kind==="absent")return w(this.rootConfig);const n=t.disk,o={...n.foreign,...n.known};for(const i of k)i!=="domains"&&this.sessionChangedKey(i)&&(this.rootConfig[i]===void 0?delete o[i]:U(o,this.rootConfig,i));const r=this.mergeDomains(n.known.domains);return r!==void 0?o.domains=r:delete o.domains,w(o)}sessionChangedKey(e){return JSON.stringify(this.rootConfig[e])!==JSON.stringify(this.loadedSnapshot[e])}mergeDomains(e){const t=this.rootConfig.domains,n=this.loadedSnapshot.domains??[];if(t===void 0&&n.length===0)return e;const o=p(a=>a.toLowerCase(),"norm"),r=t??[],i=new Map(n.map(a=>[o(a.name),a])),l=new Map(r.map(a=>[o(a.name),a])),d=[],y=new Set;for(const a of e??[]){const m=o(a.name);y.add(m);const O=l.get(m),v=i.get(m);if(O!==void 0&&!D(O,v)){d.push(O);continue}O===void 0&&v!==void 0||d.push(a)}for(const a of r){const m=o(a.name);y.has(m)||D(a,i.get(m))||d.push(a)}return d}static readDiskConfigForMerge(e){if(!s.existsSync(e))return{kind:"absent"};let t;try{const r=s.readFileSync(e,{encoding:"utf8"});if(r==="")return{kind:"absent"};t=JSON.parse(r)}catch(r){return{kind:"unmergeable",reason:`the existing file could not be read or parsed as JSON (${C(E(r))})`}}const n=N.safeParse(t);if(!n.success){const r=n.error.issues.map(i=>`${i.path.join(".")}: ${i.message}`).join("; ");return{kind:"unmergeable",reason:`a recognised key in the existing file is malformed (${C(r)})`}}const o={};if(typeof t=="object"&&t!==null){const r=k;for(const[i,l]of Object.entries(t))r.includes(i)||(o[i]=l)}return{kind:"ok",disk:{known:n.data,foreign:o}}}static getConfigDirectory(e){return f.findConfigDirectory(e)}getConfigPath(){return this.configPath}isContentUnavailable(){return this.loadFailed||this.parseDegraded}static warnedLegacyActiveTarget=!1;localConfigCache;resolveProjectDirectory(){return this.configPath!==null?g.dirname(this.configPath):f.findConfigDirectory(this.searchStartDir)}localConfigFile(){const e=this.resolveProjectDirectory();return e===null?null:g.join(e,M,G)}loadLocalConfig(){if(this.localConfigCache===void 0){const e=this.localConfigFile();if(e===null)return this.localConfigCache={};try{const t=s.readFileSync(e,{encoding:"utf8"}),n=K.safeParse(JSON.parse(t));this.localConfigCache=n.success?n.data:{}}catch{this.localConfigCache={}}}return this.localConfigCache}writeLocalConfig(e){const t=this.localConfigFile();if(t===null)return h(new Error("Cannot persist local state: no fjall-config.json was found from this directory, so there is no project to anchor `.fjall/local.json` to. Run this from inside a fjall project, or run `fjall create account` / `fjall create app` first."));try{s.mkdirSync(g.dirname(t),{recursive:!0});const n=`${t}.tmp-${process.pid}`;s.writeFileSync(n,JSON.stringify(e,null,2)+`
2
- `,{encoding:"utf8"}),s.renameSync(n,t),I(g.dirname(g.dirname(t)),[x,...R],{header:b,marker:$})}catch(n){return h(new Error(`Failed to write ${t}: ${C(E(n))}`))}return this.localConfigCache=e,w(void 0)}static warnLegacyActiveTargetOnce(){f.warnedLegacyActiveTarget||(f.warnedLegacyActiveTarget=!0,S.warn("Config","`activeTarget` in fjall-config.json is deprecated and ignored: a committed target must never re-aim a deploy or CI pipeline. Run `fjall target set <name>` to store it locally in .fjall/local.json, then remove `activeTarget` from the committed fjall-config.json."))}getActiveTarget(){const e=this.loadLocalConfig().activeTarget;if(e!==void 0)return e;this.rootConfig.activeTarget!==void 0&&f.warnLegacyActiveTargetOnce()}setActiveTarget(e){const t=this.loadLocalConfig();return t.activeTarget=e,this.writeLocalConfig(t)}clearActiveTarget(){const e=this.loadLocalConfig();return delete e.activeTarget,this.writeLocalConfig(e)}getDomains(){return this.rootConfig.domains??[]}setDomains(e){this.rootConfig.domains=e}addDomain(e){this.rootConfig.domains||(this.rootConfig.domains=[]),this.rootConfig.domains.push(e)}getDomain(e){return this.rootConfig.domains?.find(t=>t.name.toLowerCase()===e.toLowerCase())}removeDomain(e){if(!this.rootConfig.domains)return!1;const t=this.rootConfig.domains.findIndex(n=>n.name.toLowerCase()===e.toLowerCase());return t===-1?!1:(this.rootConfig.domains.splice(t,1),!0)}getOrganisation(){return this.rootConfig.organisation}setOrganisation(e){this.rootConfig.organisation=e}clearOrganisation(){delete this.rootConfig.organisation}}export{ie as ACCOUNT_TRAIL_NAME,ae as ACCOUNT_TRAIL_STATES,f as Config,j as DomainConfigSchema,ge as LOCAL_CONFIG_DIRNAME,ue as LOCAL_CONFIG_FILENAME,re as ORGANISATION_TRAIL_NAME,le as ORG_TRAIL_BUCKET_OUTPUT_KEY,L as OrganisationBindingSchema,ne as ROOT_ACCESS_MANAGEMENT_MODES,he as ROOT_CONFIG_FILENAME,N as RootConfigReadSchema,_ as RootConfigSchema,te as S3_BPA_MODES,se as TRAIL_BUCKET_OUTPUT_KEY,ce as TRAIL_KEY_ARN_OUTPUT_KEY,oe as TRAIL_LIFECYCLE_STATES,ee as VAULT_LOCK_MODES,J as serialiseRootConfig};
1
+ var O=Object.defineProperty;var T=(w,e)=>O(w,"name",{value:e,configurable:!0});import*as o from"fs";import*as i from"path";import{z as y}from"zod";import{failure as d,success as u}from"./docker/result.js";import{getErrorMessage as h}from"./errorUtils.js";import{logger as p}from"./logger.js";import{maskSensitiveOutput as m}from"./securityHelpers.js";import{mergeRootConfig as S,readRootConfigForMerge as A}from"./rootConfigMerge.js";import{RootConfigReadSchema as v,RootConfigSchema as L,serialiseRootConfig as R}from"./rootConfigSchema.js";import{DomainConfigSchema as ae,OrganisationBindingSchema as se,ROOT_CONFIG_KEYS as ce,RootConfigReadSchema as le,RootConfigSchema as fe,serialiseRootConfig as de}from"./rootConfigSchema.js";import{mergeRootConfig as ue,mergeRootConfigContent as he,readRootConfigForMerge as me}from"./rootConfigMerge.js";import{ensureGitignoreEntries as _}from"./scaffold/ensureGitignore.js";import{FJALL_MANAGED_STATE_DIR_ENTRIES as F,FJALL_PROJECT_GITIGNORE_HEADER as j,LOCAL_CONFIG_GITIGNORE_ENTRY as D,LOCAL_STATE_GITIGNORE_MARKER as I}from"./scaffold/gitignore.js";import{LOCAL_CONFIG_DIRNAME as N,LOCAL_CONFIG_FILENAME as $,ROOT_CONFIG_FILENAME as C}from"./configPaths.js";import{LOCAL_CONFIG_DIRNAME as pe,LOCAL_CONFIG_FILENAME as ye,ROOT_CONFIG_FILENAME as Ee}from"./configPaths.js";const M=10,Z=["compliance","governance","none"],z=["enforced","off"],V=["centralised","off"],X=["account","draining","org"],q="managementEvents",Q="organisationManagementEvents",ee=["active","draining","removed"],te="FjallTrailBucketName",re="FjallTrailKeyArn",oe="OrganisationTrailBucketName",b=y.object({activeTarget:y.string().optional()});class n{static{T(this,"Config")}rootConfig;configPath=null;loadFailed=!1;parseDegraded=!1;loadedSnapshot={};searchStartDir;constructor(e,t){this.rootConfig=e??{},this.configPath=t??null}static findConfigDirectory(e){let t=e!==void 0&&e!==""?e:process.cwd();for(let r=0;r<M;r++){const a=i.join(t,"fjall"),f=i.join(a,C);if(o.existsSync(f))return a;const c=i.join(t,C);if(o.existsSync(c))return t;const s=i.dirname(t);if(s===t)break;t=s}return null}static loadConfigFile(e){try{return o.accessSync(e,o.constants.R_OK),o.readFileSync(e,{encoding:"utf8"})}catch(t){return p.warn("Config",`Config file at ${e} could not be read; using defaults`,{file:e,error:m(h(t))}),null}}static loadConfig(e){const t=n.findConfigDirectory(e);if(!t){const l=new n;return l.searchStartDir=e,l}const r=i.join(t,C),a=n.loadConfigFile(r);if(a===null){const l=new n(void 0,r);return l.searchStartDir=e,l.loadFailed=!0,l}let f,c=!1;if(a!==""){let l;try{l=JSON.parse(a)}catch(g){throw n.formatZodError(g,C)}const E=L.safeParse(l);if(E.success)f=E.data;else{const g=v.safeParse(l);f=g.success?g.data:{},c=!g.success,g.success?p.warn("Config","fjall-config.json contains keys this version does not recognise (only activeTarget, domains, capacityIdentity and organisation are read). They were most likely written by a newer fjall version \u2014 leave them in place; saves preserve them verbatim.",{file:r}):p.warn("Config","fjall-config.json has a malformed activeTarget, domains or capacityIdentity value, so its contents were ignored for this run \u2014 any capacity-identity pins it holds will NOT apply (a pinned stack deploys unpinned and is blocked by the rename gate). Fix the file by hand; do NOT regenerate it, which would discard the pins.",{file:r})}}const s=new n(f,r);return s.searchStartDir=e,s.parseDegraded=c,s.loadedSnapshot=structuredClone(s.rootConfig),s}static formatZodError(e,t){if(e instanceof y.ZodError&&e.issues.length>0){const f=e.issues.map(c=>`${c.path.join(".")}: ${c.message}`).join("; ");return new Error(`Failed to parse ${t}: ${f}`)}const a=(e instanceof Error?e.message:String(e)).replace(/\n/g," ").substring(0,500);return new Error(`Failed to parse ${t}: ${a}`)}saveConfig(){const e=i.join(this.resolveProjectDirectory()??i.join(this.searchStartDir??process.cwd(),"fjall"),C);if(this.loadFailed)return d(new Error(`Refusing to save ${e}: the file exists but could not be read when this config loaded, so saving would replace its contents with state that never included them. Fix the file permissions (e.g. chmod u+rw ${e}) and retry.`));const t=i.dirname(e);try{o.mkdirSync(t,{recursive:!0})}catch(s){return d(new Error(`Cannot create config directory ${t}: ${m(h(s))}`))}const r=n.assertWritable(e,t);if(!r.success)return r;const a=this.mergeWithDisk(e);if(!a.success)return a;const f=R(a.data),c=`${e}.tmp-${process.pid}`;try{o.writeFileSync(c,f,{mode:384}),o.renameSync(c,e)}catch(s){return d(new Error(`Failed to save ${e}: ${m(h(s))}`))}return this.configPath=e,u(void 0)}static assertWritable(e,t){if(o.existsSync(e))try{o.accessSync(e,o.constants.W_OK)}catch{return d(new Error(`Cannot save ${e}: the file is read-only. Make it writable (e.g. chmod u+w ${e}) and retry.`))}try{o.accessSync(t,o.constants.W_OK)}catch{return d(new Error(`Cannot save ${e}: the directory ${t} is not writable. Make it writable (e.g. chmod u+w ${t}) and retry.`))}return u(void 0)}mergeWithDisk(e){const t=n.readDiskConfigForMerge(e);return t.kind==="unmergeable"?d(new Error(`Refusing to save ${e}: ${t.reason}. Writing now would keep only this session's state and drop everything else the file carries (including keys written by other fjall versions). Fix or remove the file and retry.`)):t.kind==="absent"?u(this.rootConfig):u(S(t.disk,this.rootConfig,this.loadedSnapshot))}static readDiskConfigForMerge(e){if(!o.existsSync(e))return{kind:"absent"};let t;try{t=o.readFileSync(e,{encoding:"utf8"})}catch(r){return{kind:"unmergeable",reason:`the existing file could not be read or parsed as JSON (${m(h(r))})`}}return A(t)}static getConfigDirectory(e){return n.findConfigDirectory(e)}getConfigPath(){return this.configPath}isContentUnavailable(){return this.loadFailed||this.parseDegraded}static warnedLegacyActiveTarget=!1;localConfigCache;resolveProjectDirectory(){return this.configPath!==null?i.dirname(this.configPath):n.findConfigDirectory(this.searchStartDir)}localConfigFile(){const e=this.resolveProjectDirectory();return e===null?null:i.join(e,N,$)}loadLocalConfig(){if(this.localConfigCache===void 0){const e=this.localConfigFile();if(e===null)return this.localConfigCache={};try{const t=o.readFileSync(e,{encoding:"utf8"}),r=b.safeParse(JSON.parse(t));this.localConfigCache=r.success?r.data:{}}catch{this.localConfigCache={}}}return this.localConfigCache}writeLocalConfig(e){const t=this.localConfigFile();if(t===null)return d(new Error("Cannot persist local state: no fjall-config.json was found from this directory, so there is no project to anchor `.fjall/local.json` to. Run this from inside a fjall project, or run `fjall create account` / `fjall create app` first."));try{o.mkdirSync(i.dirname(t),{recursive:!0});const r=`${t}.tmp-${process.pid}`;o.writeFileSync(r,JSON.stringify(e,null,2)+`
2
+ `,{encoding:"utf8"}),o.renameSync(r,t),_(i.dirname(i.dirname(t)),[D,...F],{header:j,marker:I})}catch(r){return d(new Error(`Failed to write ${t}: ${m(h(r))}`))}return this.localConfigCache=e,u(void 0)}static warnLegacyActiveTargetOnce(){n.warnedLegacyActiveTarget||(n.warnedLegacyActiveTarget=!0,p.warn("Config","`activeTarget` in fjall-config.json is deprecated and ignored: a committed target must never re-aim a deploy or CI pipeline. Run `fjall target set <name>` to store it locally in .fjall/local.json, then remove `activeTarget` from the committed fjall-config.json."))}getActiveTarget(){const e=this.loadLocalConfig().activeTarget;if(e!==void 0)return e;this.rootConfig.activeTarget!==void 0&&n.warnLegacyActiveTargetOnce()}setActiveTarget(e){const t=this.loadLocalConfig();return t.activeTarget=e,this.writeLocalConfig(t)}clearActiveTarget(){const e=this.loadLocalConfig();return delete e.activeTarget,this.writeLocalConfig(e)}getDomains(){return this.rootConfig.domains??[]}setDomains(e){this.rootConfig.domains=e}addDomain(e){this.rootConfig.domains||(this.rootConfig.domains=[]),this.rootConfig.domains.push(e)}getDomain(e){return this.rootConfig.domains?.find(t=>t.name.toLowerCase()===e.toLowerCase())}removeDomain(e){if(!this.rootConfig.domains)return!1;const t=this.rootConfig.domains.findIndex(r=>r.name.toLowerCase()===e.toLowerCase());return t===-1?!1:(this.rootConfig.domains.splice(t,1),!0)}getOrganisation(){return this.rootConfig.organisation}setOrganisation(e){this.rootConfig.organisation=e}clearOrganisation(){delete this.rootConfig.organisation}}export{q as ACCOUNT_TRAIL_NAME,ee as ACCOUNT_TRAIL_STATES,n as Config,ae as DomainConfigSchema,pe as LOCAL_CONFIG_DIRNAME,ye as LOCAL_CONFIG_FILENAME,Q as ORGANISATION_TRAIL_NAME,oe as ORG_TRAIL_BUCKET_OUTPUT_KEY,se as OrganisationBindingSchema,V as ROOT_ACCESS_MANAGEMENT_MODES,Ee as ROOT_CONFIG_FILENAME,ce as ROOT_CONFIG_KEYS,le as RootConfigReadSchema,fe as RootConfigSchema,z as S3_BPA_MODES,te as TRAIL_BUCKET_OUTPUT_KEY,re as TRAIL_KEY_ARN_OUTPUT_KEY,X as TRAIL_LIFECYCLE_STATES,Z as VAULT_LOCK_MODES,ue as mergeRootConfig,he as mergeRootConfigContent,me as readRootConfigForMerge,de as serialiseRootConfig};
@@ -21,8 +21,8 @@ export declare const MIGRATION_SNAPSHOT_NAME_PREFIX: "fjall-premigrate";
21
21
  * step reads it to keep every `fjall-premigrate-*` snapshot younger than the
22
22
  * PITR window and delete the rest (newest-N floor applies regardless). Both
23
23
  * sides import this name so a rename cannot leave the construct injecting an
24
- * env the runner no longer reads — which would silently pin the runner to its
25
- * default retention.
24
+ * env the runner no longer reads — which would silently disable age-based
25
+ * pruning (the runner has no default window: it warns and skips the sweep).
26
26
  */
27
27
  export declare const MIGRATION_SNAPSHOT_RETENTION_DAYS_ENV: "MIGRATION_SNAPSHOT_RETENTION_DAYS";
28
28
  /**
@@ -0,0 +1,90 @@
1
+ import { type RootConfig, type RootConfigRead } from "./rootConfigSchema.js";
2
+ /**
3
+ * The root `fjall-config.json` merge, as pure functions.
4
+ *
5
+ * The file has two writers — the CLI's `Config.saveConfig` and the webapp
6
+ * scaffold — and a merge each writer implements separately drifts silently: a
7
+ * rule tightened on one side ships green through the other's typecheck, lint
8
+ * and tests. Everything here is bytes-and-values only (no `fs`, no `path`, no
9
+ * logger) so both writers can call the SAME implementation from their own
10
+ * I/O boundary.
11
+ */
12
+ /**
13
+ * On-disk state re-read immediately before a save: the recognised keys
14
+ * (tolerantly parsed) plus any top-level keys this fjall version does not
15
+ * recognise, carried through the save verbatim so another version's config
16
+ * survives ours.
17
+ */
18
+ export interface RootConfigDiskState {
19
+ known: RootConfigRead;
20
+ foreign: Record<string, unknown>;
21
+ }
22
+ /**
23
+ * Outcome of classifying the existing file. `absent` (no file, or a zero-byte
24
+ * one) lets a first-ever save proceed unmerged; `unmergeable` (corrupt JSON, a
25
+ * non-object JSON value, or a malformed recognised key) REFUSES the save — the
26
+ * caller's in-memory state came through the tolerant stripping read, so writing
27
+ * it unmerged would silently delete every key the merge could not see (e.g. a
28
+ * newer version's `capacityIdentity` pins).
29
+ *
30
+ * `reason` is pre-masked at construction — every arm in `readRootConfigForMerge`
31
+ * (this file) routes echoed file content through `maskSensitiveOutput` or is a
32
+ * static literal, and the CLI's file-read arm (`Config.readDiskConfigForMerge`,
33
+ * `util/src/config.ts`) masks the same field — so consumers may surface it to
34
+ * any sink directly without re-masking.
35
+ */
36
+ export type RootConfigMergeRead = {
37
+ kind: "absent";
38
+ } | {
39
+ kind: "unmergeable";
40
+ reason: string;
41
+ } | {
42
+ kind: "ok";
43
+ disk: RootConfigDiskState;
44
+ };
45
+ /**
46
+ * Classify the BYTES of an existing fjall-config.json for a merge. `undefined`
47
+ * and `""` are both `absent` — loader parity: `Config.loadConfig` treats a
48
+ * zero-byte file as a valid empty config, so there is nothing to preserve and
49
+ * nothing to refuse over.
50
+ *
51
+ * Reasons are masked at construction (JSON parse errors and zod issue text
52
+ * both echo file content), so callers may surface them to any sink directly.
53
+ */
54
+ export declare function readRootConfigForMerge(raw: string | undefined): RootConfigMergeRead;
55
+ /**
56
+ * Session-intent merge: start from the freshly-read disk state and assert ONLY
57
+ * what the session actually changed relative to its load-time `snapshot`. An
58
+ * untouched key keeps the disk's current value, the `domains` key merges PER
59
+ * ENTRY (keyed by name) so concurrently-registered siblings survive, keys this
60
+ * version does not recognise ride through verbatim, and keys the session
61
+ * explicitly cleared are removed even when the disk copy still carries them.
62
+ *
63
+ * The returned value deliberately carries the foreign keys at runtime -
64
+ * preserving another version's config is the point, not schema drift on OUR
65
+ * keys.
66
+ */
67
+ export declare function mergeRootConfig(disk: RootConfigDiskState, live: RootConfig, snapshot: RootConfig): RootConfig;
68
+ /**
69
+ * `reason` carries `RootConfigMergeRead.reason` verbatim and is therefore
70
+ * pre-masked at construction (see that type).
71
+ */
72
+ export type RootConfigMergeResult = {
73
+ kind: "ok";
74
+ content: string;
75
+ } | {
76
+ kind: "unmergeable";
77
+ reason: string;
78
+ };
79
+ /**
80
+ * Bytes-in → bytes-out composition of the two functions above: classify the
81
+ * existing file, merge the session's intent into it, and serialise. An absent
82
+ * (or zero-byte) file writes `live` as-is.
83
+ *
84
+ * `snapshot` defaults to `{}` — the shape of a writer whose whole state IS
85
+ * session intent, such as the webapp scaffold asserting exactly
86
+ * `{ organisation }` against a config it never loaded. A writer that loaded
87
+ * the file first MUST pass the state it loaded, or every key it read back
88
+ * would be re-asserted over whatever a concurrent process has since written.
89
+ */
90
+ export declare function mergeRootConfigContent(existing: string | undefined, live: RootConfig, snapshot?: RootConfig): RootConfigMergeResult;
@@ -0,0 +1 @@
1
+ var O=Object.defineProperty;var f=(o,e)=>O(o,"name",{value:e,configurable:!0});import{getErrorMessage as C}from"./errorUtils.js";import{ROOT_CONFIG_KEYS as l,RootConfigReadSchema as S,copyDefinedKey as E,domainEntriesEqual as k,serialiseRootConfig as y}from"./rootConfigSchema.js";import{maskSensitiveOutput as h}from"./securityHelpers.js";function N(o){if(o===void 0||o==="")return{kind:"absent"};let e;try{e=JSON.parse(o)}catch(n){return{kind:"unmergeable",reason:`the existing file could not be read or parsed as JSON (${h(C(n))})`}}if(typeof e!="object"||e===null||Array.isArray(e))return{kind:"unmergeable",reason:"it does not contain a JSON object"};const r=S.safeParse(e);if(!r.success){const n=r.error.issues.map(a=>`${a.path.join(".")}: ${a.message}`).join("; ");return{kind:"unmergeable",reason:`a recognised key in the existing file is malformed (${h(n)})`}}const t={},s=l;for(const[n,a]of Object.entries(e))s.includes(n)||(t[n]=a);return{kind:"ok",disk:{known:r.data,foreign:t}}}f(N,"readRootConfigForMerge");function R(o,e,r){return JSON.stringify(o[r])!==JSON.stringify(e[r])}f(R,"sessionChangedKey");function J(o,e,r){const t=e.domains,s=r.domains??[];if(t===void 0&&s.length===0)return o;const n=f(i=>i.toLowerCase(),"norm"),a=t??[],m=new Map(s.map(i=>[n(i.name),i])),b=new Map(a.map(i=>[n(i.name),i])),c=[],g=new Set;for(const i of o??[]){const d=n(i.name);g.add(d);const u=b.get(d),p=m.get(d);if(u!==void 0&&!k(u,p)){c.push(u);continue}u===void 0&&p!==void 0||c.push(i)}for(const i of a){const d=n(i.name);g.has(d)||k(i,m.get(d))||c.push(i)}return c}f(J,"mergeDomains");function j(o,e,r){const t={...o.foreign,...o.known};for(const n of l)n!=="domains"&&R(e,r,n)&&(e[n]===void 0?delete t[n]:E(t,e,n));const s=J(o.known.domains,e,r);return s!==void 0?t.domains=s:delete t.domains,t}f(j,"mergeRootConfig");function $(o,e,r={}){const t=N(o);return t.kind==="unmergeable"?{kind:"unmergeable",reason:t.reason}:t.kind==="absent"?{kind:"ok",content:y(e)}:{kind:"ok",content:y(j(t.disk,e,r))}}f($,"mergeRootConfigContent");export{j as mergeRootConfig,$ as mergeRootConfigContent,N as readRootConfigForMerge};
@@ -0,0 +1,136 @@
1
+ import { z } from "zod";
2
+ /**
3
+ * Wire contract for the root `fjall-config.json` — the schemas, the canonical
4
+ * serialiser, and the shape helpers the merge needs.
5
+ *
6
+ * Split out of `config.ts` so `rootConfigMerge.ts` stays a pure module: the
7
+ * merge needs these schemas but must not drag `config.ts`'s fs/path/logger
8
+ * graph in with them, because the webapp imports the merge from its own I/O
9
+ * boundary. `config.ts` re-exports the schemas, their companion types, the
10
+ * serialiser and ROOT_CONFIG_KEYS, so `@fjall/util/config` is unchanged for
11
+ * consumers; `RootConfigKey`, `copyDefinedKey` and `domainEntriesEqual` are
12
+ * merge-internal and deliberately not re-exported.
13
+ */
14
+ /**
15
+ * STRICT wire contract for one `domains[]` entry in fjall-config.json —
16
+ * unknown keys are rejected, never silently carried. This is the write-path
17
+ * schema shared with the webapp's domain-topology resolver; a scaffold or
18
+ * saver emitting a key this schema rejects is exactly the drift it prevents.
19
+ */
20
+ export declare const DomainConfigSchema: z.ZodObject<{
21
+ name: z.ZodString;
22
+ type: z.ZodEnum<{
23
+ apex: "apex";
24
+ delegated: "delegated";
25
+ }>;
26
+ parentDomain: z.ZodOptional<z.ZodString>;
27
+ account: z.ZodOptional<z.ZodString>;
28
+ region: z.ZodOptional<z.ZodString>;
29
+ }, z.core.$strict>;
30
+ export type DomainConfig = z.infer<typeof DomainConfigSchema>;
31
+ /**
32
+ * STRICT wire contract for the `organisation` key — the project's binding to
33
+ * ONE Fjall organisation.
34
+ *
35
+ * This is the ONE organisation fact that belongs in fjall-config.json, and it
36
+ * is not a mirror of API state: it is a project ASSERTION that says which
37
+ * organisation this source tree deploys into. Two writers, both recording
38
+ * stated intent: `fjall create organisation` writes it at scaffold time (the
39
+ * organisation being created IS the command's subject — the project is born
40
+ * bound), and `fjall org bind` records or deliberately re-records it for
41
+ * projects that predate birth binding or genuinely move. Everything else
42
+ * about an organisation (accounts, regions, tiers) is mutable API state that
43
+ * a committed copy would misreport, which is why it stays out of this file.
44
+ *
45
+ * `id` is the only field ever compared. `name` is display-only, so renaming an
46
+ * organisation neither trips the guard nor dirties a checkout.
47
+ */
48
+ export declare const OrganisationBindingSchema: z.ZodObject<{
49
+ id: z.ZodString;
50
+ name: z.ZodOptional<z.ZodString>;
51
+ }, z.core.$strict>;
52
+ export type OrganisationBinding = z.infer<typeof OrganisationBindingSchema>;
53
+ export declare const RootConfigSchema: z.ZodObject<{
54
+ activeTarget: z.ZodOptional<z.ZodString>;
55
+ domains: z.ZodOptional<z.ZodArray<z.ZodObject<{
56
+ name: z.ZodString;
57
+ type: z.ZodEnum<{
58
+ apex: "apex";
59
+ delegated: "delegated";
60
+ }>;
61
+ parentDomain: z.ZodOptional<z.ZodString>;
62
+ account: z.ZodOptional<z.ZodString>;
63
+ region: z.ZodOptional<z.ZodString>;
64
+ }, z.core.$strict>>>;
65
+ capacityIdentity: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodString, z.ZodObject<{
66
+ legacyAnchor: z.ZodString;
67
+ legacyOwnerId: z.ZodOptional<z.ZodString>;
68
+ legacySgDescription: z.ZodOptional<z.ZodString>;
69
+ }, z.core.$strict>>>>>;
70
+ organisation: z.ZodOptional<z.ZodObject<{
71
+ id: z.ZodString;
72
+ name: z.ZodOptional<z.ZodString>;
73
+ }, z.core.$strict>>;
74
+ }, z.core.$strict>;
75
+ export type RootConfig = z.infer<typeof RootConfigSchema>;
76
+ /**
77
+ * Tolerant READ schema for the deploy-time load path. Intentionally NOT
78
+ * `.strict()`: a fjall-config.json written by a different fjall version, or a
79
+ * legacy pre-config-split file, may carry top-level keys this version does not
80
+ * know (`version`, `services`, `providerAccounts`, `generatorVersion`, …).
81
+ * Stripping them and reading only the recognised keys keeps `fjall deploy` from
82
+ * hard-failing on an otherwise-harmless older file. The strict RootConfigSchema
83
+ * remains the contract for the WRITE/save path — never use this schema to
84
+ * validate what you are about to write. Domain entries stay nested-strict
85
+ * (DomainConfigSchema): tolerance applies to unknown TOP-LEVEL keys only.
86
+ */
87
+ export declare const RootConfigReadSchema: z.ZodObject<{
88
+ activeTarget: z.ZodOptional<z.ZodString>;
89
+ domains: z.ZodOptional<z.ZodArray<z.ZodObject<{
90
+ name: z.ZodString;
91
+ type: z.ZodEnum<{
92
+ apex: "apex";
93
+ delegated: "delegated";
94
+ }>;
95
+ parentDomain: z.ZodOptional<z.ZodString>;
96
+ account: z.ZodOptional<z.ZodString>;
97
+ region: z.ZodOptional<z.ZodString>;
98
+ }, z.core.$strict>>>;
99
+ capacityIdentity: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodString, z.ZodObject<{
100
+ legacyAnchor: z.ZodString;
101
+ legacyOwnerId: z.ZodOptional<z.ZodString>;
102
+ legacySgDescription: z.ZodOptional<z.ZodString>;
103
+ }, z.core.$strict>>>>>;
104
+ organisation: z.ZodOptional<z.ZodObject<{
105
+ id: z.ZodString;
106
+ name: z.ZodOptional<z.ZodString>;
107
+ }, z.core.$strict>>;
108
+ }, z.core.$strip>;
109
+ export type RootConfigRead = z.infer<typeof RootConfigReadSchema>;
110
+ /**
111
+ * Canonical serialiser for the root fjall-config.json - the single source of
112
+ * truth for the file's on-disk shape. Both `Config.saveConfig` and the webapp
113
+ * scaffold MUST route through this rather than hand-rolling the JSON, so the
114
+ * two can never drift (a scaffold emitting a key the loader later rejects is
115
+ * exactly the bug this prevents). The `RootConfig` input type keeps OUR keys
116
+ * honest at compile time, but the emitted JSON is deliberately NOT
117
+ * strict-validated: `mergeRootConfig` routes top-level keys another fjall
118
+ * version wrote through the save verbatim (see its doc), so a merged save may
119
+ * carry keys RootConfigSchema does not recognise. The round-trip test asserts
120
+ * only that the emitted DEFAULT validates against RootConfigSchema.
121
+ */
122
+ export declare function serialiseRootConfig(config?: RootConfig): string;
123
+ export type RootConfigKey = keyof RootConfig;
124
+ /**
125
+ * Top-level keys this version recognises, derived from the strict write
126
+ * schema so a key added there cannot silently start being classified as
127
+ * another version's foreign key by the merge.
128
+ */
129
+ export declare const ROOT_CONFIG_KEYS: readonly RootConfigKey[];
130
+ export declare function copyDefinedKey<K extends RootConfigKey>(target: RootConfig, source: RootConfig, key: K): void;
131
+ /**
132
+ * Exact equality for one `domains[]` entry. DomainConfigSchema is flat and
133
+ * strict (every value a primitive string), so a key-union shallow compare is
134
+ * a complete equality - revisit if the schema ever grows a nested key.
135
+ */
136
+ export declare function domainEntriesEqual(a: DomainConfig, b: DomainConfig | undefined): boolean;
@@ -0,0 +1 @@
1
+ var p=Object.defineProperty;var a=(o,n)=>p(o,"name",{value:n,configurable:!0});import{z as t}from"zod";import{CapacityIdentityConfigSchema as r}from"./capacityIdentity.js";const c=t.object({name:t.string(),type:t.enum(["apex","delegated"]),parentDomain:t.string().optional(),account:t.string().optional(),region:t.string().optional()}).strict(),s=t.object({id:t.string().min(1),name:t.string().min(1).optional()}).strict(),f=t.object({activeTarget:t.string().optional(),domains:t.array(c).optional(),capacityIdentity:r.optional(),organisation:s.optional()}).strict(),d=t.object({activeTarget:t.string().optional(),domains:t.array(c).optional(),capacityIdentity:r.optional(),organisation:s.optional()});function y(o={}){return JSON.stringify(o,null,2)}a(y,"serialiseRootConfig");const u=f.keyof().options;function x(o,n,e){const i=n[e];i!==void 0&&(o[e]=i)}a(x,"copyDefinedKey");function S(o,n){if(n===void 0)return!1;const e=new Set([...Object.keys(o),...Object.keys(n)]);for(const i of e)if(o[i]!==n[i])return!1;return!0}a(S,"domainEntriesEqual");export{c as DomainConfigSchema,s as OrganisationBindingSchema,u as ROOT_CONFIG_KEYS,d as RootConfigReadSchema,f as RootConfigSchema,x as copyDefinedKey,S as domainEntriesEqual,y as serialiseRootConfig};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fjall/util",
3
- "version": "28.2.0",
3
+ "version": "28.4.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/fjall-tech/fjall.git",