@drkmttr/adhere 0.15.0 → 0.18.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.
@@ -1,3 +1,4 @@
1
+ import type { SavedKey } from "#providers/provider.ts";
1
2
  import { Config, Context, Effect, FileSystem, Layer, Option, Path, Redacted, Schema } from "effect";
2
3
 
3
4
  /** No key to send, or the saved key cannot be read or written. */
@@ -6,37 +7,67 @@ export class CredentialsUnavailable extends Schema.TaggedError<CredentialsUnavai
6
7
  { message: Schema.String },
7
8
  ) {}
8
9
 
9
- /** A TypeSafe AI API key is one word: a space or a second line is a bad paste. */
10
+ /** An API key is one word: a space or a second line is a bad paste. */
10
11
  const ApiKey = Schema.String.pipe(Schema.check(Schema.isPattern(/^\S+$/)));
11
12
 
12
- /** What `adhere login` saves. */
13
+ /** What `adhere login` saves: TypeSafe AI's key, and with `--openai`, OpenAI's. */
13
14
  class SavedCredentials extends Schema.Class<SavedCredentials>("SavedCredentials")({
14
- apiKey: Schema.RedactedFromValue(ApiKey),
15
+ apiKey: Schema.optionalKey(Schema.RedactedFromValue(ApiKey)),
16
+ openaiApiKey: Schema.optionalKey(Schema.RedactedFromValue(ApiKey)),
15
17
  }) {}
16
18
 
17
19
  const SavedCredentialsJson = Schema.fromJsonString(SavedCredentials);
18
20
 
19
- /** The TypeSafe AI API key: where a request gets it, and where `adhere login` keeps it. */
21
+ /** The saved keys, by the name a provider asks for them by. */
22
+ type Saved = Partial<Record<SavedKey, Redacted.Redacted<string>>>;
23
+
24
+ /**
25
+ * Each key: the variable that wins over the saved one, how `adhere login`
26
+ * asks for it and names it, and what a run says when there is none, or when
27
+ * the file holds none.
28
+ */
29
+ export const KEYS = {
30
+ typesafe: {
31
+ variable: "TYPESAFE_API_KEY",
32
+ label: "TypeSafe AI API key",
33
+ said: "API key",
34
+ missing: "No TypeSafe AI API key. Run `adhere login` to save one, or set TYPESAFE_API_KEY.",
35
+ absent: (file: string) => `${file} holds no API key. Run \`adhere login\` to save one again.`,
36
+ },
37
+ openai: {
38
+ variable: "OPENAI_API_KEY",
39
+ label: "OpenAI API key",
40
+ said: "OpenAI API key",
41
+ missing: "No OpenAI API key. Run `adhere login --openai` to save one, or set OPENAI_API_KEY.",
42
+ absent: (file: string) =>
43
+ `${file} holds no OpenAI API key. Run \`adhere login --openai\` to save one.`,
44
+ },
45
+ } as const satisfies Record<SavedKey, unknown>;
46
+
47
+ /** The API keys providers ask with: where a request gets one, and where `adhere login` keeps them. */
20
48
  export class Credentials extends Context.Service<
21
49
  Credentials,
22
50
  {
23
- /** `TYPESAFE_API_KEY` when it is set, otherwise the key `adhere login` saved. */
24
- readonly apiKey: Effect.Effect<Redacted.Redacted<string>, CredentialsUnavailable>;
25
- /** Where the saved key lives, whether or not there is one. */
51
+ /** `name`'s key: its variable, such as `TYPESAFE_API_KEY`, when set, otherwise the one `adhere login` saved. */
52
+ readonly key: (
53
+ name: SavedKey,
54
+ ) => Effect.Effect<Redacted.Redacted<string>, CredentialsUnavailable>;
55
+ /** Where the saved keys live, whether or not there are any. */
26
56
  readonly file: Effect.Effect<string, CredentialsUnavailable>;
27
- /** Saves a key for later runs, readable only by the user. */
57
+ /** Saves `name`'s key for later runs, beside the other, readable only by the user. */
28
58
  readonly save: (
59
+ name: SavedKey,
29
60
  apiKey: Redacted.Redacted<string>,
30
61
  ) => Effect.Effect<void, CredentialsUnavailable>;
31
- /** Deletes the saved key. False when there was none. */
32
- readonly remove: Effect.Effect<boolean, CredentialsUnavailable>;
62
+ /** Deletes `name`'s saved key, and the file with no key left in it. False when there was none. */
63
+ readonly remove: (name: SavedKey) => Effect.Effect<boolean, CredentialsUnavailable>;
33
64
  }
34
65
  >()("@drkmttr/adhere/services/Credentials") {}
35
66
 
36
67
  const refused = (message: string) => CredentialsUnavailable.make({ message });
37
68
 
38
69
  /**
39
- * The key lives in `$XDG_CONFIG_HOME/adhere/credentials.json`, or under
70
+ * The keys live in `$XDG_CONFIG_HOME/adhere/credentials.json`, or under
40
71
  * `~/.config` without it, on every platform. The file is written whole to a
41
72
  * temporary file created readable only by the user, then renamed over the
42
73
  * old one, so no reader sees half a key or a key with looser permissions.
@@ -59,65 +90,84 @@ export const CredentialsLive = Layer.effect(Credentials)(
59
90
  ),
60
91
  );
61
92
 
62
- const exists = (target: string) =>
63
- fs
64
- .exists(target)
65
- .pipe(Effect.mapError((problem) => refused(`Cannot read ${target}: ${problem.message}`)));
66
-
67
- const readSaved = Effect.fn("Credentials.readSaved")(function* (target: string) {
68
- const text = yield* fs
69
- .readFileString(target)
70
- .pipe(Effect.mapError((problem) => refused(`Cannot read ${target}: ${problem.message}`)));
71
- const saved = yield* Schema.decodeUnknownEffect(SavedCredentialsJson)(text).pipe(
93
+ /**
94
+ * The keys the file holds, or nothing when there is no file. A file that
95
+ * does not read as keys refuses, so nothing writes over what it holds.
96
+ */
97
+ const savedIn = Effect.fn("Credentials.savedIn")(function* (target: string) {
98
+ const text = yield* Effect.gen(function* () {
99
+ return (yield* fs.exists(target)) ? yield* fs.readFileString(target) : undefined;
100
+ }).pipe(Effect.mapError((problem) => refused(`Cannot read ${target}: ${problem.message}`)));
101
+ if (text === undefined) return undefined;
102
+ const { apiKey, openaiApiKey } = yield* Schema.decodeUnknownEffect(SavedCredentialsJson)(
103
+ text,
104
+ ).pipe(
72
105
  Effect.mapError(() =>
73
- refused(`${target} holds no API key. Run \`adhere login\` to save one again.`),
106
+ refused(
107
+ `${target} does not hold keys as \`adhere login\` saves them. Fix it, or delete it and run \`adhere login\` again.`,
108
+ ),
74
109
  ),
75
110
  );
76
- return saved.apiKey;
111
+ return {
112
+ ...(apiKey === undefined ? {} : { typesafe: apiKey }),
113
+ ...(openaiApiKey === undefined ? {} : { openai: openaiApiKey }),
114
+ } satisfies Saved;
115
+ });
116
+
117
+ /** Writes the keys whole, as the layer's comment says; the file goes when none is left. */
118
+ const write = Effect.fn("Credentials.write")(function* (target: string, saved: Saved) {
119
+ const temporary = `${target}.tmp`;
120
+ const json = yield* Schema.encodeEffect(SavedCredentialsJson)(
121
+ new SavedCredentials({
122
+ ...(saved.typesafe === undefined ? {} : { apiKey: saved.typesafe }),
123
+ ...(saved.openai === undefined ? {} : { openaiApiKey: saved.openai }),
124
+ }),
125
+ ).pipe(Effect.orDie);
126
+ yield* Effect.gen(function* () {
127
+ if (saved.typesafe === undefined && saved.openai === undefined) {
128
+ return yield* fs.remove(target, { force: true });
129
+ }
130
+ yield* fs.makeDirectory(path.dirname(target), { recursive: true, mode: 0o700 });
131
+ // A mode applies only when a file is created: never reuse a leftover.
132
+ yield* fs.remove(temporary, { force: true });
133
+ yield* fs.writeFileString(temporary, `${json}\n`, { mode: 0o600 });
134
+ yield* fs.rename(temporary, target);
135
+ }).pipe(Effect.mapError((problem) => refused(`Cannot save ${target}: ${problem.message}`)));
77
136
  });
78
137
 
79
138
  // Read when a request needs the key, not before: a cached run needs none.
80
- const apiKey = Effect.fn("Credentials.apiKey")(function* () {
81
- const fromEnvironment = yield* Config.option(Config.Redacted("TYPESAFE_API_KEY")).pipe(
82
- Effect.mapError((problem) => refused(`Cannot read TYPESAFE_API_KEY: ${problem.message}`)),
139
+ const key = Effect.fn("Credentials.key")(function* (name: SavedKey) {
140
+ const { variable, missing, absent } = KEYS[name];
141
+ const fromEnvironment = yield* Config.option(Config.Redacted(variable)).pipe(
142
+ Effect.mapError((problem) => refused(`Cannot read ${variable}: ${problem.message}`)),
83
143
  );
84
144
  if (Option.isSome(fromEnvironment)) return fromEnvironment.value;
85
145
  const target = yield* file;
86
- if (!(yield* exists(target))) {
87
- return yield* refused(
88
- "No TypeSafe AI API key. Run `adhere login` to save one, or set TYPESAFE_API_KEY.",
89
- );
90
- }
91
- return yield* readSaved(target);
146
+ const saved = yield* savedIn(target);
147
+ if (saved === undefined) return yield* refused(missing);
148
+ return saved[name] ?? (yield* refused(absent(target)));
92
149
  });
93
150
 
94
- const save = Effect.fn("Credentials.save")(function* (key: Redacted.Redacted<string>) {
95
- const saved = yield* Schema.decodeUnknownEffect(SavedCredentials)({
96
- apiKey: Redacted.value(key),
97
- }).pipe(
151
+ const save = Effect.fn("Credentials.save")(function* (
152
+ name: SavedKey,
153
+ apiKey: Redacted.Redacted<string>,
154
+ ) {
155
+ yield* Schema.decodeUnknownEffect(ApiKey)(Redacted.value(apiKey)).pipe(
98
156
  Effect.mapError(() => refused("An API key is one word, with no spaces or line breaks.")),
99
157
  );
100
- const json = yield* Schema.encodeEffect(SavedCredentialsJson)(saved).pipe(Effect.orDie);
101
158
  const target = yield* file;
102
- const temporary = `${target}.tmp`;
103
- yield* Effect.gen(function* () {
104
- yield* fs.makeDirectory(path.dirname(target), { recursive: true, mode: 0o700 });
105
- // A mode applies only when a file is created: never reuse a leftover.
106
- yield* fs.remove(temporary, { force: true });
107
- yield* fs.writeFileString(temporary, `${json}\n`, { mode: 0o600 });
108
- yield* fs.rename(temporary, target);
109
- }).pipe(Effect.mapError((problem) => refused(`Cannot save ${target}: ${problem.message}`)));
159
+ yield* write(target, { ...(yield* savedIn(target)), [name]: apiKey });
110
160
  });
111
161
 
112
- const remove = Effect.fn("Credentials.remove")(function* () {
162
+ const remove = Effect.fn("Credentials.remove")(function* (name: SavedKey) {
113
163
  const target = yield* file;
114
- if (!(yield* exists(target))) return false;
115
- yield* fs
116
- .remove(target)
117
- .pipe(Effect.mapError((problem) => refused(`Cannot delete ${target}: ${problem.message}`)));
164
+ const rest: Saved = { ...(yield* savedIn(target)) };
165
+ if (rest[name] === undefined) return false;
166
+ delete rest[name];
167
+ yield* write(target, rest);
118
168
  return true;
119
169
  });
120
170
 
121
- return Credentials.of({ apiKey: apiKey(), file, save, remove: remove() });
171
+ return Credentials.of({ key, file, save, remove });
122
172
  }),
123
173
  );