store/plan.bend
cswap-bend-cli@0.1.0.0 · 307 lines · 55 definitions
1import Base
2import cswap-bend-cli@0.1.0.0/json.bend as Json
3import cswap-bend-cli@0.1.0.0/act.bend as Act
4import cswap-bend-cli@0.1.0.0/store/base.bend as SBase
5import cswap-bend-cli@0.1.0.0/store/seq.bend as SSeq
6
7
8
9
10
11type Prof is Data:
12 ProfNone{}
13 Prof{uuid: String, email: Maybe<&2, String>, org: Maybe<&2, String>}
14
15def opt_str(+rec: Json.Json, +key: String) -> Maybe<&2, String>:
16 Maybe.bind(&2, Json.Json, String, Json.get(key, rec), s => Json.as_string(s))
17
18def org_uuid_of(+o: Maybe<&2, Json.Json>) -> Maybe<&2, String>:
19 match o:
20 case None{}:
21 None{}
22 case Some{rec}:
23 opt_str(rec, "uuid")
24
25def prof3b(+empty: Bool, +uuid: String, +email: Maybe<&2, String>, +org: Maybe<&2, String>) -> Prof:
26 match empty:
27 case True{}:
28 ProfNone{}
29 case False{}:
30 Prof{uuid, email, org}
31
32def prof3(+uuid: String, +email: Maybe<&2, String>, +org: Maybe<&2, String>) -> Prof:
33 prof3b(String.is_empty(uuid), uuid, email, org)
34
35def prof2(+acct: Maybe<&2, Json.Json>, +org: Maybe<&2, String>) -> Prof:
36 match acct:
37 case None{}:
38 ProfNone{}
39 case Some{a}:
40 prof3(String.trim(SSeq.field_str(a, "uuid")), opt_str(a, "email"), org)
41
42
43def profile_of(+j: Json.Json) -> Prof:
44 prof2(Json.get("account", j), org_uuid_of(Json.get("organization", j)))
45
46def profile_of_res(r: Result<&2, &2, String, Json.Json>) -> Prof:
47 match r:
48 case Fail{e}:
49 ProfNone{}
50 case Done{j}:
51 profile_of(j)
52
53def profile_of_text(+body: String) -> Prof:
54 profile_of_res(Json.parse(body))
55
56
57type Plan is Data:
58 PSkip{why: String}
59 PFetch{token: String}
60
61
62def expires_s(+ms: String) -> Maybe<&2, U32>:
63 U32.read(String.take(ms, Nat.sub(String.length(ms), 3n)))
64
65def token_of(+creds: Json.Json) -> String:
66 SSeq.field_str(SSeq.get_or("claudeAiOauth", creds, Json.JNull{}), "accessToken")
67
68def expiry_text(+m: Maybe<&2, Json.Json>) -> Maybe<&2, U32>:
69 match m:
70 case Some{Json.JNum{t}}:
71 expires_s(t)
72 case _:
73 None{}
74
75def expiry_of(+creds: Json.Json) -> Maybe<&2, U32>:
76 expiry_text(Json.get("expiresAt", SSeq.get_or("claudeAiOauth", creds, Json.JNull{})))
77
78def plan5(+expired: Bool, +token: String) -> Plan:
79 match expired:
80 case True{}:
81 PSkip{"the access token is expired"}
82 case False{}:
83 PFetch{token}
84
85def plan4(+token: String, +expiry: Maybe<&2, U32>, +now: U32) -> Plan:
86 match expiry:
87 case Some{e}:
88 plan5(U32.is_le(e, now), token)
89 case None{}:
90 PFetch{token}
91
92def plan3b(+empty: Bool, +token: String, +expiry: Maybe<&2, U32>, +now: U32) -> Plan:
93 match empty:
94 case True{}:
95 PSkip{"no access token to resolve"}
96 case False{}:
97 plan4(token, expiry, now)
98
99def plan3(+token: String, +expiry: Maybe<&2, U32>, +now: U32) -> Plan:
100 plan3b(String.is_empty(token), token, expiry, now)
101
102def plan_res(r: Result<&2, &2, String, Json.Json>, +now: U32) -> Plan:
103 match r:
104 case Fail{e}:
105 PSkip{"no access token to resolve"}
106 case Done{+j}:
107 plan3(token_of(j), expiry_of(j), now)
108
109def plan_for(+creds: String, +now: U32) -> Plan:
110 plan_res(Json.parse(creds), now)
111
112type Verdict is Data:
113 VOk{}
114 VUnverified{why: String}
115 VBad{msg: String}
116
117def bad_uuid(+email: String, +seen: String, +want: String) -> String:
118 "The stored credential does not belong to " ++ email ++ ": the token resolves to account " ++ seen ++ ", not " ++ want
119 ++ ". Nothing was changed. This happens when the config names one account while the credential store still holds another's token (e.g. a renamed .claude.json over a live keychain item). Log in as "
120 ++ email ++ " in THIS environment, then re-run."
121
122def bad_email(+email: String, +seen: String) -> String:
123 "The stored credential does not belong to " ++ email ++ ": the token resolves to " ++ seen
124 ++ ". Nothing was changed. This happens when the config names one account while the credential store still holds another's token (e.g. a renamed .claude.json over a live keychain item). Log in as "
125 ++ email ++ " in THIS environment, then re-run."
126
127def bad_org(+email: String, +seen: String, +want: String) -> String:
128 "The stored credential for " ++ email ++ " belongs to organization " ++ SBase.sel.s(String.is_empty(seen), "personal", seen)
129 ++ ", not " ++ SBase.sel.s(String.is_empty(want), "personal", want) ++ ". Nothing was changed. Two accounts can share an email across organizations. Log in as "
130 ++ email ++ " in the " ++ SBase.sel.s(String.is_empty(want), "personal", want) ++ " organization in THIS environment, then re-run."
131
132def org_verdict2(+same: Bool, +email: String, +seen: String, +want: String) -> Verdict:
133 match same:
134 case True{}:
135 VOk{}
136 case False{}:
137 VBad{bad_org(email, seen, want)}
138
139def org_verdict(+email: String, +want: String, +org: Maybe<&2, String>) -> Verdict:
140 match org:
141 case None{}:
142 VOk{}
143 case Some{o}:
144 org_verdict2(String.eq(String.trim(o), want), email, String.trim(o), want)
145
146
147def then_org(+v: Verdict, +email: String, +want: String, +org: Maybe<&2, String>) -> Verdict:
148 match v:
149 case VOk{}:
150 org_verdict(email, want, org)
151 case _:
152 v
153
154def sel.v(+c: Bool, +x: Verdict, +y: Verdict) -> Verdict:
155 match c:
156 case True{}:
157 x
158 case False{}:
159 y
160
161def ident_by_email2(+empty: Bool, +email: String, +seen: String) -> Verdict:
162 match empty:
163 case True{}:
164 VUnverified{"the resolved identity carries no address"}
165 case False{}:
166 sel.v(String.eq(String.to_lower(seen), String.to_lower(email)), VOk{}, VBad{bad_email(email, seen)})
167
168def ident_by_email(+email: String, +seen: String) -> Verdict:
169 ident_by_email2(String.is_empty(seen), email, seen)
170
171def ident_verdict2(+no_uuid: Bool, +email: String, +acct_uuid: String, +uuid: String, +pemail: Maybe<&2, String>) -> Verdict:
172 match no_uuid:
173 case True{}:
174 ident_by_email(email, String.trim(SSeq.str_or(pemail, "")))
175 case False{}:
176 sel.v(String.eq(uuid, acct_uuid), VOk{}, VBad{bad_uuid(email, uuid, acct_uuid)})
177
178def ident_verdict(+email: String, +acct_uuid: String, +uuid: String, +pemail: Maybe<&2, String>) -> Verdict:
179 ident_verdict2(String.is_empty(acct_uuid), email, acct_uuid, uuid, pemail)
180
181
182
183
184def judge(+email: String, +org: String, +acct_uuid: String, +p: Prof) -> Verdict:
185 match p:
186 case ProfNone{}:
187 VUnverified{"the identity lookup did not resolve"}
188 case Prof{uuid, pemail, porg}:
189 then_org(ident_verdict(email, acct_uuid, uuid, pemail), email, org, porg)
190
191
192
193
194
195type SlotPlan is Data:
196 SPOk{num: String, displace: Maybe<&2, String>, migrate: Maybe<&2, String>}
197 SPErr{code: U32, msg: String}
198
199def sel.m(+c: Bool, +x: Maybe<&2, String>, +y: Maybe<&2, String>) -> Maybe<&2, String>:
200 match c:
201 case True{}:
202 x
203 case False{}:
204 y
205
206def differs(+m: Maybe<&2, String>, +num: String) -> Maybe<&2, String>:
207 match m:
208 case Some{old}:
209 sel.m(String.eq(old, num), None{}, Some{old})
210 case None{}:
211 None{}
212
213def occupied2(+same: Bool, +assume_yes: Bool, +num: String, +other: String) -> SlotPlan:
214 match same assume_yes:
215 case True{} _:
216 SPOk{num, None{}, None{}}
217 case False{} True{}:
218 SPOk{num, Some{num}, None{}}
219 case False{} False{}:
220 SPErr{10, "Slot " ++ num ++ " already occupied by " ++ other}
221
222def occupied(+rec: Maybe<&2, Json.Json>, +email: String, +org: String, +assume_yes: Bool, +num: String) -> SlotPlan:
223 match rec:
224 case None{}:
225 SPOk{num, None{}, None{}}
226 case Some{r}:
227 occupied2(SSeq.rec_is(r, email, org), assume_yes, num, SSeq.field_str(r, "email"))
228
229def with_migrate(+p: SlotPlan, +m: Maybe<&2, String>) -> SlotPlan:
230 match p:
231 case SPOk{num, d, old}:
232 SPOk{num, d, m}
233 case _:
234 p
235
236def plan_slot2(+n: U32, +seq: Json.Json, +email: String, +org: String, +assume_yes: Bool) -> SlotPlan:
237 with_migrate(occupied(SSeq.slot_record(seq, U32.show(n)), email, org, assume_yes, U32.show(n)),
238 differs(SSeq.find_slot(seq, email, org), U32.show(n)))
239
240def plan_slot1(+low: Bool, +n: U32, +seq: Json.Json, +email: String, +org: String, +assume_yes: Bool) -> SlotPlan:
241 match low:
242 case True{}:
243 SPErr{1, "Slot number must be >= 1"}
244 case False{}:
245 plan_slot2(n, seq, email, org, assume_yes)
246
247def plan_slot(+slot: Maybe<&2, U32>, +seq: Json.Json, +email: String, +org: String, +assume_yes: Bool) -> SlotPlan:
248 match slot:
249 case None{}:
250 SPOk{U32.show(SSeq.next_slot(seq)), None{}, None{}}
251 case Some{n}:
252 plan_slot1(U32.is_lt(n, 1), n, seq, email, org, assume_yes)
253
254
255
256def with_kind(+api_key: Bool, +rec: Json.Json) -> Json.Json:
257 match api_key:
258 case True{}:
259 Json.set("kind", SSeq.jstr("api_key"), rec)
260 case False{}:
261 rec
262
263def token_record(+email: String, +ts: String, +api_key: Bool) -> Json.Json:
264 with_kind(api_key, Json.JObj{Json.JField{"email", SSeq.jstr(email), Json.JField{"uuid", SSeq.jstr(""),
265 Json.JField{"organizationUuid", SSeq.jstr(""), Json.JField{"organizationName", SSeq.jstr(""), Json.JField{"added", SSeq.jstr(ts), Json.JNil{}}}}}}})
266
267def with_alias2(+empty: Bool, +alias: String, +rec: Json.Json) -> Json.Json:
268 match empty:
269 case True{}:
270 rec
271 case False{}:
272 Json.set("alias", SSeq.jstr(alias), rec)
273
274def with_alias(+alias: String, +rec: Json.Json) -> Json.Json:
275 with_alias2(String.is_empty(alias), alias, rec)
276
277
278def setup_token_creds(+token: String) -> String:
279 Json.emit_py(SSeq.obj1("claudeAiOauth", Json.JObj{Json.JField{"accessToken", SSeq.jstr(token),
280 Json.JField{"scopes", Json.arr([SSeq.jstr("user:inference")]), Json.JNil{}}}}))
281
282
283def token_config(+email: String) -> String:
284 Json.emit_py(SSeq.obj1("oauthAccount", Json.JObj{Json.JField{"emailAddress", SSeq.jstr(email), Json.JField{"accountUuid", SSeq.jstr(""),
285 Json.JField{"organizationUuid", Json.JNull{}, Json.JField{"organizationName", Json.JNull{}, Json.JNil{}}}}}}))
286
287def default_email(+api_key: Bool, +slot: U32) -> String:
288 SBase.sel.s(api_key, "api-key", "setup-token") ++ "-" ++ U32.show(slot) ++ "@token.local"
289
290def kind_label(+api_key: Bool) -> String:
291 SBase.sel.s(api_key, "API key", "token")
292
293def kind_name(+api_key: Bool) -> String:
294 SBase.sel.s(api_key, "api_key", "oauth")
295
296def kind_title(+api_key: Bool) -> String:
297 SBase.sel.s(api_key, "API-key", "OAuth")
298
299
300def ActM(-A: Type) -> Type:
301 Act.of(A)
302
303def ActM.pure(-A: Type, x: A) -> Act.of(A):
304 Act.pure(A, x)
305
306def ActM.bind(-A: Type, -B: Type, m: Act.of(A), f: A -> Act.of(B)) -> Act.of(B):
307 Act.bind(A, B, m, f)