@townco/env 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@townco/env",
3
+ "type": "module",
4
+ "version": "0.1.1",
5
+ "description": "env",
6
+ "license": "UNLICENSED",
7
+ "main": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "repository": "github:townco/town",
10
+ "exports": {
11
+ ".": {
12
+ "import": "./dist/index.js",
13
+ "types": "./dist/index.d.ts"
14
+ },
15
+ "./update-schema": {
16
+ "import": "./dist/update-schema.js",
17
+ "types": "./dist/update-schema.d.ts"
18
+ }
19
+ },
20
+ "devDependencies": {
21
+ "@townco/tsconfig": "0.1.48",
22
+ "ts-morph": "^27.0.2",
23
+ "typescript": "^5.9.3"
24
+ },
25
+ "scripts": {
26
+ "build": "tsc",
27
+ "check": "tsc --noEmit",
28
+ "schema:update": "bun src/update-schema"
29
+ },
30
+ "dependencies": {
31
+ "@townco/core": "0.0.29",
32
+ "zod": "^4.1.13"
33
+ }
34
+ }
@@ -0,0 +1,248 @@
1
+ /**
2
+ * Represents a line in an .env file
3
+ */
4
+ type EnvLine =
5
+ | { type: "comment"; content: string }
6
+ | { type: "blank" }
7
+ | { type: "entry"; key: string; value: string; raw: string };
8
+
9
+ type EnvEntry = Extract<EnvLine, { type: "entry" }>;
10
+
11
+ /**
12
+ * A data structure that represents a parsed .env file while preserving
13
+ * all original content including comments, blank lines, and order.
14
+ */
15
+ export class EnvFile {
16
+ private lines: EnvLine[];
17
+
18
+ constructor(lines: EnvLine[] = []) {
19
+ this.lines = lines;
20
+ }
21
+
22
+ /**
23
+ * Parse a .env file string into an EnvFile structure
24
+ */
25
+ static parse(content: string): EnvFile {
26
+ const lines: EnvLine[] = content.split("\n").map((line): EnvLine => {
27
+ const trimmed = line.trim();
28
+
29
+ if (trimmed === "") {
30
+ return { type: "blank" };
31
+ }
32
+
33
+ if (trimmed.startsWith("#")) {
34
+ return { type: "comment", content: line };
35
+ }
36
+
37
+ const equalsIndex = line.indexOf("=");
38
+ if (equalsIndex === -1) {
39
+ // Malformed line, treat as comment
40
+ return { type: "comment", content: line };
41
+ }
42
+
43
+ const key = line.substring(0, equalsIndex).trim();
44
+ const value = line.substring(equalsIndex + 1);
45
+
46
+ return { type: "entry", key, value, raw: line };
47
+ });
48
+
49
+ return new EnvFile(lines);
50
+ }
51
+
52
+ /**
53
+ * Serialize the EnvFile back to a string
54
+ */
55
+ toString(): string {
56
+ return this.lines
57
+ .map((line) => {
58
+ switch (line.type) {
59
+ case "blank":
60
+ return "";
61
+ case "comment":
62
+ return line.content;
63
+ case "entry":
64
+ return line.raw;
65
+ default:
66
+ throw new Error(`Unknown line type`);
67
+ }
68
+ })
69
+ .join("\n");
70
+ }
71
+
72
+ /**
73
+ * Get all entries as a key-value record
74
+ */
75
+ toRecord(): Record<string, string> {
76
+ return Object.fromEntries(this.entries());
77
+ }
78
+
79
+ /**
80
+ * Find an entry line by key
81
+ */
82
+ private findEntry(key: string): EnvEntry | undefined {
83
+ const line = this.lines.find((l) => l.type === "entry" && l.key === key);
84
+ return line as EnvEntry | undefined;
85
+ }
86
+
87
+ /**
88
+ * Find the index of an entry by key
89
+ */
90
+ private findEntryIndex(key: string): number {
91
+ return this.lines.findIndex((l) => l.type === "entry" && l.key === key);
92
+ }
93
+
94
+ /**
95
+ * Create an entry line from key and value
96
+ */
97
+ private createEntry(key: string, value: string): EnvEntry {
98
+ const quotedValue = this.normalizeValue(value);
99
+ return {
100
+ type: "entry",
101
+ key,
102
+ value: quotedValue,
103
+ raw: `${key}=${quotedValue}`,
104
+ };
105
+ }
106
+
107
+ /**
108
+ * Normalize a value by ensuring it's properly quoted
109
+ */
110
+ private normalizeValue(value: string): string {
111
+ // Strip existing quotes if present, then add quotes
112
+ const unquoted =
113
+ value.startsWith('"') && value.endsWith('"') ? value.slice(1, -1) : value;
114
+ return `"${unquoted}"`;
115
+ }
116
+
117
+ /**
118
+ * Find the insertion index for a new entry (before the final blank line if present)
119
+ */
120
+ private findInsertionIndex(): number {
121
+ const lastLine = this.lines[this.lines.length - 1];
122
+ return lastLine?.type === "blank"
123
+ ? this.lines.length - 1
124
+ : this.lines.length;
125
+ }
126
+
127
+ /**
128
+ * Get the value for a specific key
129
+ */
130
+ get(key: string): string | undefined {
131
+ return this.findEntry(key)?.value;
132
+ }
133
+
134
+ /**
135
+ * Set a value for a key. If the key exists, updates it in place.
136
+ * If it doesn't exist, appends it above the final newline (if present).
137
+ * Values are always double quoted.
138
+ */
139
+ set(key: string, value: string): this {
140
+ const index = this.findEntryIndex(key);
141
+ const entry = this.createEntry(key, value);
142
+
143
+ if (index !== -1) {
144
+ this.lines[index] = entry;
145
+ } else {
146
+ this.lines.splice(this.findInsertionIndex(), 0, entry);
147
+ }
148
+
149
+ return this;
150
+ }
151
+
152
+ /**
153
+ * Delete a key-value entry
154
+ */
155
+ delete(key: string): this {
156
+ this.lines = this.lines.filter(
157
+ (l) => !(l.type === "entry" && l.key === key),
158
+ );
159
+ return this;
160
+ }
161
+
162
+ /**
163
+ * Check if a key exists
164
+ */
165
+ has(key: string): boolean {
166
+ return this.findEntry(key) !== undefined;
167
+ }
168
+
169
+ /**
170
+ * Get all keys
171
+ */
172
+ keys(): string[] {
173
+ return this.lines
174
+ .filter((l): l is EnvEntry => l.type === "entry")
175
+ .map((l) => l.key);
176
+ }
177
+
178
+ /**
179
+ * Iterate over all entries
180
+ */
181
+ *entries(): IterableIterator<[string, string]> {
182
+ for (const line of this.lines) {
183
+ if (line.type === "entry") {
184
+ yield [line.key, line.value];
185
+ }
186
+ }
187
+ }
188
+
189
+ /**
190
+ * Apply a function to all entries and return a new EnvFile
191
+ */
192
+ map(fn: (key: string, value: string) => [string, string]): EnvFile {
193
+ const newLines = this.lines.map((line) => {
194
+ if (line.type === "entry") {
195
+ const [newKey, newValue] = fn(line.key, line.value);
196
+ return this.createEntry(newKey, newValue);
197
+ }
198
+ return line;
199
+ });
200
+
201
+ return new EnvFile(newLines);
202
+ }
203
+
204
+ /**
205
+ * Filter entries based on a predicate
206
+ */
207
+ filter(fn: (key: string, value: string) => boolean): EnvFile {
208
+ const newLines = this.lines.filter((line) => {
209
+ if (line.type === "entry") {
210
+ return fn(line.key, line.value);
211
+ }
212
+ return true; // Keep comments and blank lines
213
+ });
214
+
215
+ return new EnvFile(newLines);
216
+ }
217
+
218
+ /**
219
+ * Add a comment line
220
+ */
221
+ addComment(content: string): this {
222
+ const comment = content.startsWith("#") ? content : `# ${content}`;
223
+ this.lines.push({ type: "comment", content: comment });
224
+ return this;
225
+ }
226
+
227
+ /**
228
+ * Add a blank line
229
+ */
230
+ addBlank(): this {
231
+ this.lines.push({ type: "blank" });
232
+ return this;
233
+ }
234
+
235
+ /**
236
+ * Get the raw lines array for custom operations
237
+ */
238
+ getLines(): readonly EnvLine[] {
239
+ return this.lines;
240
+ }
241
+
242
+ /**
243
+ * Create a clone of this EnvFile
244
+ */
245
+ clone(): EnvFile {
246
+ return new EnvFile([...this.lines]);
247
+ }
248
+ }
package/src/index.ts ADDED
@@ -0,0 +1,22 @@
1
+ import { z } from "zod";
2
+
3
+ export const envSchema = z.object({
4
+ ANTHROPIC_API_KEY: z.string().min(1),
5
+ AWS_ACCESS_KEY_ID: z.string().min(1),
6
+ AWS_SECRET_ACCESS_KEY: z.string().min(1),
7
+ EXA_API_KEY: z.string().min(1),
8
+ FLY_TOKEN: z.string().min(1),
9
+ NEXT_PUBLIC_SITE_URL: z.string().min(1),
10
+ NEXT_PUBLIC_SUPABASE_ANON_KEY: z.string().min(1),
11
+ NEXT_PUBLIC_SUPABASE_URL: z.string().min(1),
12
+ SUPABASE_ACCESS_TOKEN: z.string().min(1),
13
+ SUPABASE_ANON_KEY: z.string().min(1),
14
+ SUPABASE_DB_PASSWORD: z.string().min(1),
15
+ SUPABASE_PROJECT_ID: z.string().min(1),
16
+ SUPABASE_SRC_ARC_BUCKET: z.string().min(1),
17
+ SUPABASE_URL: z.string().min(1),
18
+ });
19
+
20
+ export type Env = z.infer<typeof envSchema>;
21
+ export const parseEnv = (env = process.env) => envSchema.parse(env);
22
+ export { EnvFile } from "./env-file";
@@ -0,0 +1,47 @@
1
+ #!/usr/bin/env bun
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { Biome } from "@biomejs/js-api/nodejs";
5
+ import { findRoot } from "@townco/core";
6
+ import { Project, SyntaxKind } from "ts-morph";
7
+
8
+ const envSchemaTsSrc = path.join("packages", "env", "src", "index.ts");
9
+
10
+ export const updateSchema = async ({
11
+ envSchemaSrc = envSchemaTsSrc,
12
+ ...opts
13
+ }: {
14
+ envFile?: string;
15
+ envSchemaSrc?: string;
16
+ } = {}) => {
17
+ const envFile = opts.envFile ?? path.join(await findRoot(), ".env.in");
18
+ const root = await findRoot();
19
+ const keys = (await Bun.file(envFile).text())
20
+ .split("\n")
21
+ .filter((line) => line.match(/^[A-Z_]+\=/))
22
+ .map((line) => line.split("=")[0])
23
+ .filter((k): k is string => Boolean(k))
24
+ .sort();
25
+
26
+ const src = new Project().addSourceFileAtPath(envSchemaSrc);
27
+ const obj = src
28
+ .getVariableDeclaration("envSchema")!
29
+ .getInitializerIfKindOrThrow(SyntaxKind.CallExpression)
30
+ .getArguments()[0]
31
+ ?.asKindOrThrow(SyntaxKind.ObjectLiteralExpression);
32
+
33
+ obj?.getProperties().forEach((p) => p.remove());
34
+ obj?.addPropertyAssignments(
35
+ keys.map((name) => ({ name, initializer: "z.string().min(1)" })),
36
+ );
37
+
38
+ const biome = new Biome();
39
+ src.replaceWithText(
40
+ biome.formatContent(biome.openProject(root).projectKey, src.getFullText(), {
41
+ filePath: src.getFilePath(),
42
+ }).content,
43
+ );
44
+ await src.save();
45
+ };
46
+
47
+ if (import.meta.main) await updateSchema();
package/tsconfig.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "extends": "@townco/tsconfig",
3
+ "compilerOptions": { "outDir": "./dist", "rootDir": "./src" },
4
+ "include": ["src/**/*"],
5
+ "exclude": ["node_modules", "dist", ".sst"]
6
+ }