Skip to content

Commit c5ef0ff

Browse files
authored
Fix local config system (#223)
* fix local config system * fix merge conflicts * format readme * allow clearing properties * deep partial * use zod 4 * format code * vendor dep * bump version * run formatter * remove dep * tick version
1 parent 32f6add commit c5ef0ff

File tree

10 files changed

+441
-56
lines changed

10 files changed

+441
-56
lines changed

README.md

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,7 @@ $ curl https://maxm--df1d09da00cd11f0a0de569c3dd06744.web.val.run
206206
Hello GET https://maxm--df1d09da00cd11f0a0de569c3dd06744.web.val.run/
207207
```
208208

209-
### Watching for Changes
209+
## Watching for Changes
210210

211211
Oftentimes you'll end up in a workflow that looks like
212212

@@ -250,7 +250,7 @@ One common Val Town Val workflow is branching out. `vt`'s `checkout` and
250250
- `vt branch` lists all branches.
251251
- `vt branch -D` deletes a branch. You can't delete the branch you are on.
252252

253-
### Management
253+
## Management
254254

255255
- `vt list` lists all your Val Town Vals
256256
- `vt delete` deletes the current Val of the folder you are in (with
@@ -275,6 +275,17 @@ Right now, we offer the following configuration options:
275275
- `editorTemplate`: The Val URI for the editor files that you are prompted about
276276
when you run a `vt clone`, `vt remix`, or `vt create`.
277277

278+
### Multiple Accounts
279+
280+
`vt` supports project-scoped account configuration via this system. You can set
281+
local configuration options for a single `vt` folder using the `--local` flag.
282+
To set a local API key for a folder, use
283+
`vt config set --local apiKey xxxMY_ALT_API_KEYxxx`. This will store your API
284+
key for use in `vt` in `.vt/config.yaml`, rather than your global configuration.
285+
286+
You can clear a config value by using `vt config set property ""` (empty
287+
string).
288+
278289
## LLMs
279290

280291
`vt` lets you use all your favorite local LLM tools like

deno.json

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"$schema": "https://raw.githubusercontent.com/denoland/deno/348900b8b79f4a434cab4c74b3bc8d4d2fa8ee74/cli/schemas/config-file.v1.json",
33
"name": "@valtown/vt",
44
"description": "The Val Town CLI",
5-
"version": "0.1.51",
5+
"version": "0.1.52",
66
"exports": "./vt.ts",
77
"license": "MIT",
88
"tasks": {
@@ -37,12 +37,11 @@
3737
"highlight.js": "npm:highlight.js@^11.11.1",
3838
"strip-ansi": "npm:strip-ansi@^7.1.2",
3939
"word-wrap": "npm:word-wrap@^1.2.5",
40-
"zod-to-json-schema": "npm:zod-to-json-schema@^3.24.6",
4140
"zod-validation-error": "npm:zod-validation-error@^4.0.2",
4241
"emphasize": "npm:emphasize@^7.0.0",
4342
"kia": "jsr:@jonasschiano/kia@0.0.120",
4443
"open": "npm:open@^10.2.0",
45-
"zod": "npm:zod@^3.24.2",
44+
"zod": "npm:zod@^4.2.1",
4645
"~/": "./src/",
4746
"~/companion/": "./companion/"
4847
},

deno.lock

Lines changed: 6 additions & 14 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/cmd/lib/config.ts

Lines changed: 67 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -2,23 +2,26 @@ import { Command } from "@cliffy/command";
22
import VTConfig, { globalConfig } from "~/vt/VTConfig.ts";
33
import { findVtRoot } from "~/vt/vt/utils.ts";
44
import { doWithSpinner } from "~/cmd/utils.ts";
5-
import { getNestedProperty, setNestedProperty } from "~/utils.ts";
5+
import { removeNestedProperty, setNestedProperty } from "~/utils.ts";
66
import { stringify as stringifyYaml } from "@std/yaml";
77
import { VTConfigSchema } from "~/vt/vt/schemas.ts";
8-
import { zodToJsonSchema } from "zod-to-json-schema";
98
import { printYaml } from "~/cmd/styles.ts";
109
import { fromError } from "zod-validation-error";
1110
import z from "zod";
1211
import { colors } from "@cliffy/ansi/colors";
13-
import { DEFAULT_WRAP_AMOUNT, GLOBAL_VT_CONFIG_PATH } from "~/consts.ts";
12+
import {
13+
DEFAULT_WRAP_AMOUNT,
14+
GLOBAL_VT_CONFIG_PATH,
15+
LOCAL_VT_CONFIG_PATH,
16+
} from "~/consts.ts";
1417
import { join } from "@std/path";
1518
import wrap from "word-wrap";
1619
import { openEditorAt } from "~/cmd/lib/utils/openEditorAt.ts";
17-
import { Select } from "@cliffy/prompt";
20+
import { Confirm, Select } from "@cliffy/prompt";
21+
import { execSync } from "node:child_process";
1822

1923
function showConfigOptions() {
20-
// deno-lint-ignore no-explicit-any
21-
const jsonSchema = zodToJsonSchema(VTConfigSchema) as any;
24+
const jsonSchema = VTConfigSchema.toJSONSchema({ unrepresentable: "any" });
2225
delete jsonSchema["$schema"];
2326

2427
// deno-lint-ignore no-explicit-any
@@ -42,6 +45,46 @@ function showConfigOptions() {
4245
printYaml(stringifyYaml(jsonSchema["properties"]));
4346
}
4447

48+
/**
49+
* If the user tries to add an API secret to their local vt config file, offer
50+
* to add the config file to their local gitignore.
51+
*/
52+
async function offerToAddToGitignore() {
53+
const gitRoot = execSync("git rev-parse --show-toplevel", {
54+
encoding: "utf-8",
55+
stdio: ["pipe", "pipe", "ignore"],
56+
}).trim();
57+
58+
const addToIgnore = await Confirm.prompt(
59+
"You are adding an API secret to your local config file, and we noticed you have a Git repo set up for this folder.\n" +
60+
`Would you like to add \`${LOCAL_VT_CONFIG_PATH}\` to your \`.gitignore\`?`,
61+
);
62+
63+
if (addToIgnore) {
64+
let gitignoreContent = "";
65+
const gitignorePath = join(gitRoot, ".gitignore");
66+
67+
try {
68+
gitignoreContent = await Deno.readTextFile(gitignorePath);
69+
// Add a newline if the file doesn't end with one
70+
if (gitignoreContent.length > 0 && !gitignoreContent.endsWith("\n")) {
71+
gitignoreContent += "\n";
72+
}
73+
} catch (e) {
74+
if (!(e instanceof Deno.errors.NotFound)) {
75+
// If error is something other than "file not found", rethrow it
76+
throw e;
77+
}
78+
// If file doesn't exist, we'll create it with empty content
79+
}
80+
81+
// Add the path to gitignore
82+
gitignoreContent += LOCAL_VT_CONFIG_PATH + "\n";
83+
await Deno.writeTextFile(gitignorePath, gitignoreContent);
84+
console.log(`Added ${LOCAL_VT_CONFIG_PATH} to .gitignore`);
85+
}
86+
}
87+
4588
export const configWhereCmd = new Command()
4689
.name("where")
4790
.description("Show the config file locations")
@@ -70,7 +113,10 @@ export const configWhereCmd = new Command()
70113

71114
export const configSetCmd = new Command()
72115
.description("Set a configuration value")
73-
.option("--local", "Set in the local configuration (val-specific)")
116+
.option(
117+
"--local",
118+
'Set in the local configuration (val-specific). Leave value blank "" to unset.',
119+
)
74120
.arguments("<key:string> <value:string>")
75121
.example(
76122
"Set your valtown API key (global)",
@@ -90,40 +136,28 @@ export const configSetCmd = new Command()
90136
const vtConfig = new VTConfig(vtRoot);
91137

92138
const config = await vtConfig.loadConfig();
93-
const updatedConfig = setNestedProperty(config, key, value);
94-
const oldProperty = getNestedProperty(config, key, null) as
95-
| string
96-
| null;
97-
98-
if (oldProperty !== null && oldProperty.toString() === value) {
99-
throw new Error(
100-
`Property ${colors.bold(key)} is already set to ${
101-
colors.bold(oldProperty)
102-
}`,
103-
);
104-
}
105139

106-
let validatedConfig: z.infer<typeof VTConfigSchema>;
140+
const updatedConfig = value === ""
141+
? removeNestedProperty(config, key)
142+
: setNestedProperty(config, key, value);
143+
107144
try {
108145
if (useGlobal) {
109-
validatedConfig = await vtConfig.saveGlobalConfig(updatedConfig);
146+
await vtConfig.saveGlobalConfig(updatedConfig);
110147
} else {
111-
validatedConfig = await vtConfig.saveLocalConfig(updatedConfig);
148+
await vtConfig.saveLocalConfig(updatedConfig);
112149
}
113150

114-
if (JSON.stringify(config) !== JSON.stringify(validatedConfig)) {
115-
spinner.succeed(
116-
`Set ${colors.bold(`${key}=${value}`)} in ${
151+
spinner.succeed(
152+
value === ""
153+
? `Unset ${colors.bold(`${key}`)}`
154+
: `Set ${colors.bold(`${key}=${value}`)} in ${
117155
useGlobal ? "global" : "local"
118156
} configuration`,
119-
);
120-
} else {
121-
throw new Error(
122-
`Property ${colors.bold(key)} is not valid.` +
123-
`\n Use \`${
124-
colors.bold("vt config options")
125-
}\` to view config options`,
126-
);
157+
);
158+
159+
if (key === "apiKey" && value !== "" && !useGlobal) {
160+
await offerToAddToGitignore();
127161
}
128162
} catch (e) {
129163
if (e instanceof z.ZodError) {

src/consts.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export const VT_CONFIG_FILE_NAME = "config.yaml";
1818
export const META_FOLDER_NAME = ".vt";
1919
export const ENTRYPOINT_NAME = "vt.ts";
2020
export const META_IGNORE_FILE_NAME = ".vtignore";
21+
export const LOCAL_VT_CONFIG_PATH = join(META_FOLDER_NAME, VT_CONFIG_FILE_NAME);
2122
export const GLOBAL_VT_CONFIG_PATH = join(xdg.config(), PROGRAM_NAME);
2223
/** The directory that contains GLOBAL_VT_META_FILE_PATH */
2324
export const GLOBAL_VT_META_PATH = join(xdg.cache(), PROGRAM_NAME);

src/utils.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { assertEquals } from "@std/assert";
2+
import { removeNestedProperty } from "./utils.ts";
3+
4+
Deno.test("removeNestedProperty", () => {
5+
// Top level property
6+
const obj1 = { a: 1, b: 2 };
7+
const result1 = removeNestedProperty(obj1, "a");
8+
assertEquals(result1, { b: 2 });
9+
assertEquals(obj1, { a: 1, b: 2 }); // Original unchanged
10+
11+
// Nested property
12+
const obj2 = {
13+
a: 1,
14+
b: {
15+
c: 2,
16+
d: { e: 3 },
17+
},
18+
};
19+
const result2 = removeNestedProperty(obj2, "b.d.e");
20+
assertEquals(result2, { a: 1, b: { c: 2, d: {} } });
21+
assertEquals(obj2.b.d.e, 3); // Original unchanged
22+
});

src/utils.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,34 @@ export function setNestedProperty(
104104
return deepMerge(obj, valueObj);
105105
}
106106

107+
/**
108+
* Removes a value at a nested property path and returns a new object.
109+
* The original object is not modified.
110+
*
111+
* @param obj - The source object
112+
* @param path - Dot-separated property path (e.g., 'user.address.city')
113+
* @returns A new object with the specified path removed
114+
*/
115+
export function removeNestedProperty(
116+
obj: Record<string, unknown>,
117+
path: string,
118+
): Record<string, unknown> {
119+
const result = structuredClone(obj);
120+
121+
const parts = path.split(".");
122+
parts.reduce((current, part, index, array) => {
123+
if (index === array.length - 1) {
124+
// Last part - delete the property
125+
delete current[part];
126+
} else if (current[part] && typeof current[part] === "object") {
127+
return current[part] as Record<string, unknown>;
128+
}
129+
return current;
130+
}, result as Record<string, unknown>);
131+
132+
return result;
133+
}
134+
107135
/**
108136
* Checks if a directory is empty asynchronously.
109137
*

src/vt/VTConfig.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,11 @@ import {
88
} from "~/consts.ts";
99
import * as path from "@std/path";
1010
import { ensureDir, exists } from "@std/fs";
11-
import { DefaultVTConfig, VTConfigSchema } from "~/vt/vt/schemas.ts";
11+
import {
12+
DefaultVTConfig,
13+
VTConfigDeepPartial,
14+
VTConfigSchema,
15+
} from "~/vt/vt/schemas.ts";
1216
import { parse as parseYaml, stringify as stringifyYaml } from "@std/yaml";
1317
import type z from "zod";
1418
import { findVtRoot } from "~/vt/vt/utils.ts";
@@ -118,9 +122,9 @@ export default class VTConfig {
118122
*/
119123
public async saveLocalConfig(
120124
config: Record<string, unknown>,
121-
): Promise<z.infer<typeof VTConfigSchema>> {
125+
): Promise<z.infer<typeof VTConfigDeepPartial>> {
122126
// Validate the configuration against the schema
123-
const validatedConfig = VTConfigSchema.strict().parse(config);
127+
const validatedConfig = VTConfigDeepPartial.parse(config);
124128

125129
// Ensure the metadata directory exists
126130
await ensureDir(path.join(this.#localConfigPath, META_FOLDER_NAME));

src/vt/vt/schemas.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import { join } from "@std/path";
2-
import { z } from "zod";
2+
import { z } from "zod/v4";
33
import {
44
AUTH_CACHE_LOCALSTORE_ENTRY,
55
DEFAULT_EDITOR_TEMPLATE,
66
GLOBAL_VT_CONFIG_PATH,
77
META_IGNORE_FILE_NAME,
88
SAW_AS_LATEST_VERSION,
99
} from "~/consts.ts";
10+
import { zodDeepPartial } from "../zodDeepPartial.ts";
1011

1112
/**
1213
* JSON schema for the state.json file for the .vt folder.
@@ -79,6 +80,8 @@ export const VTConfigSchema = z.object({
7980
editorTemplate: z.string().optional(), // a Val URI
8081
});
8182

83+
export const VTConfigDeepPartial = zodDeepPartial(VTConfigSchema);
84+
8285
export const DefaultVTConfig: z.infer<typeof VTConfigSchema> = {
8386
apiKey: null,
8487
globalIgnoreFiles: [join(GLOBAL_VT_CONFIG_PATH, META_IGNORE_FILE_NAME)],

0 commit comments

Comments
 (0)