@brainervirus/workit-core 8.0.1 → 8.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.
@@ -2,7 +2,8 @@
2
2
  // §2.0 Identity).
3
3
  //
4
4
  // - The push remote host decides GitHub vs GitLab. A workspace provider that
5
- // disagrees is `blocked`, never silently overridden.
5
+ // disagrees is `blocked` for the forge verbs, never silently overridden;
6
+ // `git push` (checkPushIdentity) only needs git, so it pushes with a note.
6
7
  // - PRs/MRs live in the base repository: an `upstream` remote on the same
7
8
  // forge, else the push repo's fork parent, else the push repo itself.
8
9
  // - Every API call carries the workspace's credential explicitly, so the
@@ -23,9 +24,10 @@ import {
23
24
  type ForgeHosts,
24
25
  type SshConfig,
25
26
  } from "../git/rev";
26
- import { FORGE_TIMEOUTS, systemRunner, type ForgeRunner } from "./exec";
27
+ import { FORGE_TIMEOUTS, isNetworkError, systemRunner, type ForgeRunner } from "./exec";
27
28
  import { createGitHubForge } from "./github";
28
29
  import { createGitLabForge } from "./gitlab";
30
+ import { redactText } from "./redact";
29
31
  import { failure, success, type Forge, type ForgeResult, type Identity } from "./types";
30
32
 
31
33
  export type CredentialSource = "workspace_token_file" | "gh_account_token" | "cli_login";
@@ -62,51 +64,103 @@ export type ResolveOptions = {
62
64
  now?: () => number;
63
65
  /** ssh_config for Host aliases (default: the user's ~/.ssh/config). */
64
66
  sshConfig?: string | SshConfig;
67
+ /** Absolute deadline (`now()` clock) for every call, resolution included; null = none. */
68
+ deadline?: number | null;
65
69
  };
66
70
 
67
- const configuredHosts = (): ForgeHosts => {
68
- const vcs = readVcsConfig();
69
- if (vcs.status !== "valid") return {};
70
- const host = (kind: "github" | "gitlab"): string[] => {
71
- const value = (vcs.config[kind] as { host?: unknown } | undefined)?.host;
72
- return typeof value === "string" && value.trim() ? [value.trim()] : [];
71
+ // Per-process memo of credential lookups and /user answers (a long-lived MCP
72
+ // server re-asks after the TTL). Scoped by runner, so an injected test runner
73
+ // never sees another's answers, and keyed by the gh/glab config location, so
74
+ // another GH_CONFIG_DIR never reuses them; only successes are kept.
75
+ const MEMO_TTL_MS = 5 * 60_000;
76
+ const SYSTEM_SCOPE = {};
77
+ const memoScopes = new WeakMap<object, Map<string, { at: number; value: unknown }>>();
78
+
79
+ type MemoScope = { owner: object; key: string };
80
+
81
+ const memoScope = (runner: ForgeRunner | undefined, env: NodeJS.ProcessEnv): MemoScope => ({
82
+ owner: runner ?? SYSTEM_SCOPE,
83
+ key: [env.GH_CONFIG_DIR, env.GLAB_CONFIG_DIR, env.XDG_CONFIG_HOME, env.HOME]
84
+ .map((value) => value ?? "")
85
+ .join("|"),
86
+ });
87
+
88
+ function memo<T>(
89
+ scope: MemoScope,
90
+ key: string,
91
+ compute: () => { keep: boolean; value: T },
92
+ now = Date.now,
93
+ ): T {
94
+ let entries = memoScopes.get(scope.owner);
95
+ if (!entries) memoScopes.set(scope.owner, (entries = new Map()));
96
+ const full = `${scope.key} ${key}`;
97
+ const hit = entries.get(full);
98
+ if (hit && now() - hit.at < MEMO_TTL_MS) return hit.value as T;
99
+ const { keep, value } = compute();
100
+ if (keep) entries.set(full, { at: now(), value });
101
+ return value;
102
+ }
103
+
104
+ /** A runner whose calls are clamped to `limits.deadline`, carrying `token`. */
105
+ function clampedRunner(
106
+ base: ForgeRunner,
107
+ limits: { deadline: number | null },
108
+ now: () => number,
109
+ token?: string,
110
+ ): ForgeRunner {
111
+ return (bin, args, callOptions) => {
112
+ let timeoutMs = callOptions.timeoutMs;
113
+ if (limits.deadline !== null) {
114
+ const remaining = limits.deadline - now();
115
+ if (remaining <= 0)
116
+ return {
117
+ status: null,
118
+ stdout: "",
119
+ stderr: "",
120
+ timedOut: true,
121
+ missing: false,
122
+ timeoutMs: 0,
123
+ };
124
+ timeoutMs = Math.min(timeoutMs, remaining);
125
+ }
126
+ const run = base(bin, args, { ...callOptions, timeoutMs, ...(token ? { token } : {}) });
127
+ return run.timedOut && run.timeoutMs === undefined ? { ...run, timeoutMs } : run;
128
+ };
129
+ }
130
+
131
+ /**
132
+ * `forge.identity` answered once per explicit credential per TTL (push, then
133
+ * pr create, …). The CLI's own login (`cli_login`) is never memoized: a
134
+ * `glab auth login` / `gh auth switch` must show on the next call.
135
+ */
136
+ const memoizedIdentity = (forge: Forge, scope: MemoScope, credential: Credential): Forge => {
137
+ if (credential.credential === "cli_login") return forge;
138
+ const identity = (expected: string | null) => forge.identity(expected);
139
+ return {
140
+ ...forge,
141
+ identity: (expected: string | null) =>
142
+ memo(
143
+ scope,
144
+ `identity ${forge.kind} ${forge.apiHost} ${credential.credential} ${credential.token ?? ""} ${expected ?? ""}`,
145
+ () => {
146
+ const value = identity(expected);
147
+ return { keep: value.ok, value };
148
+ },
149
+ ),
73
150
  };
74
- return { github: host("github"), gitlab: host("gitlab") };
75
151
  };
76
152
 
77
- const createForge = (derived: DerivedForge, repo: string, runner: ForgeRunner): Forge =>
78
- derived.kind === "github"
79
- ? createGitHubForge({ apiHost: derived.apiHost, repo, runner })
80
- : createGitLabForge({ apiHost: derived.apiHost, repo, runner });
153
+ type Credential = { token: string | undefined; credential: CredentialSource };
81
154
 
82
- export function resolveForge(
83
- cwd: string,
84
- options: ResolveOptions = {},
85
- ): ForgeResult<ResolvedForge> {
86
- let workspace: ReturnType<typeof resolveRuntimeWorkspaceVcs>;
87
- try {
88
- workspace = resolveRuntimeWorkspaceVcs(cwd);
89
- } catch (error) {
90
- return failure(
91
- "blocked",
92
- error instanceof Error ? error.message : String(error),
93
- "fix ~/.config/workit/workspaces.json (workit init → Workspaces)",
94
- );
95
- }
96
- const hosts = configuredHosts();
97
- const pushed = pushForge(cwd, { branch: options.branch, hosts, sshConfig: options.sshConfig });
98
- if (!pushed.ok) return failure(pushed.code, pushed.error, pushed.unblock);
99
- const conflict = forgeConflict(pushed.forge, workspace?.vcs?.provider);
100
- if (conflict) return failure("blocked", conflict.error, conflict.unblock);
101
- const derived = pushed.forge;
155
+ /** The credential every API call carries (see the header). */
156
+ function resolveCredential(
157
+ workspace: ReturnType<typeof resolveRuntimeWorkspaceVcs>,
158
+ derived: DerivedForge,
159
+ run: ForgeRunner,
160
+ scope: MemoScope,
161
+ ): ForgeResult<Credential> {
102
162
  const { kind, apiHost } = derived;
103
- const base = options.runner ?? systemRunner(kind, apiHost, options.env ?? process.env);
104
- const now = options.now ?? Date.now;
105
163
  const expectedAccount = workspace?.vcs?.account ?? null;
106
-
107
- // The credential for every call.
108
- let token: string | undefined;
109
- let credential: CredentialSource = "cli_login";
110
164
  const tokenFile = (workspace?.vcs as { tokenFile?: unknown } | undefined)?.tokenFile;
111
165
  if (typeof tokenFile === "string" && tokenFile.trim()) {
112
166
  // A stored token goes only to the host it is for: the public forge (also
@@ -127,6 +181,7 @@ export function resolveForge(
127
181
  `token_host_unverified: push host ${derived.host} only looks like ${kind}; workspace ${workspace?.name ?? "?"} vcs.tokenFile is not sent to it`,
128
182
  `if ${apiHost} is your ${kind} server, set ${kind}.host in ~/.config/workit/vcs.json (or vcs.host for the workspace); otherwise fix the push remote`,
129
183
  );
184
+ let token = "";
130
185
  try {
131
186
  token = fs.readFileSync(tokenFile.trim(), "utf8").trim();
132
187
  } catch {
@@ -142,42 +197,108 @@ export function resolveForge(
142
197
  `workspace ${workspace?.name ?? "?"} vcs.tokenFile is empty`,
143
198
  "fix vcs.tokenFile in ~/.config/workit/workspaces.json",
144
199
  );
145
- credential = "workspace_token_file";
146
- } else if (expectedAccount && kind === "github") {
147
- const issued = base("gh", ["auth", "token", "--hostname", apiHost, "--user", expectedAccount], {
200
+ return success({ token, credential: "workspace_token_file" });
201
+ }
202
+ // glab keeps one login per host, so a GitLab account is checked, not selected.
203
+ if (!expectedAccount || kind !== "github")
204
+ return success({ token: undefined, credential: "cli_login" });
205
+ return memo(scope, `token ${apiHost} ${expectedAccount}`, () => {
206
+ const issued = run("gh", ["auth", "token", "--hostname", apiHost, "--user", expectedAccount], {
148
207
  timeoutMs: FORGE_TIMEOUTS.api,
149
208
  });
150
- if (issued.missing)
151
- return failure(
152
- "unavailable",
153
- "gh is not installed",
154
- "install the GitHub CLI (https://cli.github.com) and run: gh auth login",
155
- );
156
- token = issued.status === 0 ? issued.stdout.trim() : "";
157
- if (!token)
158
- return failure(
159
- "blocked",
160
- `identity_unavailable: gh has no login for ${expectedAccount} on ${apiHost} (workspace ${workspace?.name ?? "?"})`,
161
- `gh auth login --hostname ${apiHost} # sign in as ${expectedAccount}; workit passes that account's token per call and never switches the active account`,
162
- );
163
- credential = "gh_account_token";
164
- }
209
+ const value = authTokenResult(issued, apiHost, expectedAccount, workspace?.name ?? "?");
210
+ return { keep: value.ok, value };
211
+ });
212
+ }
165
213
 
166
- const limits: { deadline: number | null } = { deadline: null };
167
- const runner: ForgeRunner = (bin, args, callOptions) => {
168
- let timeoutMs = callOptions.timeoutMs;
169
- if (limits.deadline !== null) {
170
- const remaining = limits.deadline - now();
171
- if (remaining <= 0)
172
- return { status: null, stdout: "", stderr: "", timedOut: true, missing: false };
173
- timeoutMs = Math.min(timeoutMs, remaining);
174
- }
175
- return base(bin, args, { ...callOptions, timeoutMs, ...(token ? { token } : {}) });
214
+ /**
215
+ * `gh auth token --user`: a timeout or a transport error is `unavailable`
216
+ * (exit 5, retry); only gh saying it has no such login is identity_unavailable.
217
+ */
218
+ function authTokenResult(
219
+ issued: ReturnType<ForgeRunner>,
220
+ apiHost: string,
221
+ account: string,
222
+ workspace: string,
223
+ ): ForgeResult<Credential> {
224
+ if (issued.missing)
225
+ return failure(
226
+ "unavailable",
227
+ "gh is not installed",
228
+ "install the GitHub CLI (https://cli.github.com) and run: gh auth login",
229
+ );
230
+ if (issued.timedOut)
231
+ return failure(
232
+ "unavailable",
233
+ `gh auth token ${issued.timeoutMs === 0 ? "not attempted: the time budget ran out" : `timed out after ${issued.timeoutMs ?? FORGE_TIMEOUTS.api} ms`} (looking up the ${account} login on ${apiHost})`,
234
+ "gh auth status # check gh and the network, then retry",
235
+ );
236
+ const token = issued.status === 0 ? issued.stdout.trim() : "";
237
+ if (token) return success({ token, credential: "gh_account_token" });
238
+ const stderr = redactText(issued.stderr).trim();
239
+ const first = stderr.split(/\r?\n/u).find((line) => line.trim()) ?? "";
240
+ if (issued.status !== 0 && first && !/no oauth token|not logged in|no account/iu.test(stderr))
241
+ return failure(
242
+ "unavailable",
243
+ `gh auth token failed${isNetworkError(stderr) ? " (network)" : ""}: ${first.slice(0, 200)}`,
244
+ "gh auth status # check gh and the network, then retry",
245
+ );
246
+ return failure(
247
+ "blocked",
248
+ `identity_unavailable: gh has no login for ${account} on ${apiHost} (workspace ${workspace})`,
249
+ `gh auth login --hostname ${apiHost} # sign in as ${account}; workit passes that account's token per call and never switches the active account`,
250
+ );
251
+ }
252
+
253
+ const configuredHosts = (): ForgeHosts => {
254
+ const vcs = readVcsConfig();
255
+ if (vcs.status !== "valid") return {};
256
+ const host = (kind: "github" | "gitlab"): string[] => {
257
+ const value = (vcs.config[kind] as { host?: unknown } | undefined)?.host;
258
+ return typeof value === "string" && value.trim() ? [value.trim()] : [];
176
259
  };
260
+ return { github: host("github"), gitlab: host("gitlab") };
261
+ };
262
+
263
+ const createForge = (derived: DerivedForge, repo: string, runner: ForgeRunner): Forge =>
264
+ derived.kind === "github"
265
+ ? createGitHubForge({ apiHost: derived.apiHost, repo, runner })
266
+ : createGitLabForge({ apiHost: derived.apiHost, repo, runner });
267
+
268
+ export function resolveForge(
269
+ cwd: string,
270
+ options: ResolveOptions = {},
271
+ ): ForgeResult<ResolvedForge> {
272
+ let workspace: ReturnType<typeof resolveRuntimeWorkspaceVcs>;
273
+ try {
274
+ workspace = resolveRuntimeWorkspaceVcs(cwd);
275
+ } catch (error) {
276
+ return failure(
277
+ "blocked",
278
+ error instanceof Error ? error.message : String(error),
279
+ "fix ~/.config/workit/workspaces.json (workit init → Workspaces)",
280
+ );
281
+ }
282
+ const hosts = configuredHosts();
283
+ const pushed = pushForge(cwd, { branch: options.branch, hosts, sshConfig: options.sshConfig });
284
+ if (!pushed.ok) return failure(pushed.code, pushed.error, pushed.unblock);
285
+ const conflict = forgeConflict(pushed.forge, workspace?.vcs?.provider);
286
+ if (conflict) return failure("blocked", conflict.error, conflict.unblock);
287
+ const derived = pushed.forge;
288
+ const { kind, apiHost } = derived;
289
+ const base = options.runner ?? systemRunner(kind, apiHost, options.env ?? process.env);
290
+ const scope = memoScope(options.runner, options.env ?? process.env);
291
+ const now = options.now ?? Date.now;
292
+ const expectedAccount = workspace?.vcs?.account ?? null;
293
+ // The deadline covers resolution too, so a verb's --timeout bounds it all.
294
+ const limits: { deadline: number | null } = { deadline: options.deadline ?? null };
295
+ const credential = resolveCredential(workspace, derived, clampedRunner(base, limits, now), scope);
296
+ if (!credential.ok) return credential;
297
+ const runner = clampedRunner(base, limits, now, credential.data.token);
177
298
 
178
299
  // Base repository: an `upstream` remote on the same forge, else the fork parent.
179
300
  const headRepo = derived.repo;
180
- const probe = createForge(derived, headRepo, runner);
301
+ const probe = memoizedIdentity(createForge(derived, headRepo, runner), scope, credential.data);
181
302
  const info = probe.repoInfo(headRepo);
182
303
  if (!info.ok && info.code !== "not_found") return info;
183
304
  const headProjectId = info.ok ? info.data.id : null;
@@ -201,13 +322,16 @@ export function resolveForge(
201
322
  : (remotes.find((name) => repoOfRemote(name) === baseRepo) ?? pushed.remote);
202
323
 
203
324
  return success({
204
- forge: baseRepo === headRepo ? probe : createForge(derived, baseRepo, runner),
325
+ forge:
326
+ baseRepo === headRepo
327
+ ? probe
328
+ : memoizedIdentity(createForge(derived, baseRepo, runner), scope, credential.data),
205
329
  remote: pushed.remote,
206
330
  url: pushed.url,
207
331
  derived,
208
332
  workspace: workspace?.name ?? null,
209
333
  expectedAccount,
210
- credential,
334
+ credential: credential.data.credential,
211
335
  headRepo,
212
336
  headProjectId,
213
337
  fork: baseRepo !== headRepo,
@@ -225,16 +349,142 @@ export function checkIdentity(resolved: ResolvedForge): ForgeResult<Identity> {
225
349
  const { forge, expectedAccount } = resolved;
226
350
  const identity = forge.identity(expectedAccount);
227
351
  if (!identity.ok) return identity;
228
- if (identity.data.matches === false) {
229
- const bin = forge.kind === "github" ? "gh" : "glab";
230
- const where = `workspace ${resolved.workspace ?? "?"}`;
352
+ if (identity.data.matches === false)
353
+ return mismatch(resolved, identity.data.login ?? "?", resolved.workspace);
354
+ return identity;
355
+ }
356
+
357
+ const mismatch = <T>(
358
+ resolved: Pick<ResolvedForge, "forge" | "credential" | "expectedAccount">,
359
+ login: string,
360
+ workspace: string | null,
361
+ ): ForgeResult<T> => {
362
+ const { forge, expectedAccount } = resolved;
363
+ const bin = forge.kind === "github" ? "gh" : "glab";
364
+ const where = `workspace ${workspace ?? "?"}`;
365
+ const fileFix = `put a token for ${expectedAccount} in the ${where} vcs.tokenFile`;
366
+ return failure(
367
+ "blocked",
368
+ `identity_mismatch: the ${resolved.credential === "workspace_token_file" ? "vcs.tokenFile token" : `${bin} login`} is ${login} but ${where} expects ${expectedAccount}`,
369
+ resolved.credential === "workspace_token_file"
370
+ ? fileFix
371
+ : forge.kind === "github"
372
+ ? `gh auth login --hostname ${forge.apiHost} # sign in as ${expectedAccount}`
373
+ : // glab keeps one login per host; switching it would change every repo.
374
+ `${fileFix} (glab keeps one login per host, so workit never switches it)`,
375
+ );
376
+ };
377
+
378
+ /** The forge could not answer at all, as opposed to answering against the account. */
379
+ const unreachable = (result: { code: string; error: string }): boolean =>
380
+ result.code === "unavailable" && !/ is not authenticated for /u.test(result.error);
381
+
382
+ /** Total budget for the identity check before a push (B1). */
383
+ export const PUSH_IDENTITY_BUDGET_MS = 5_000;
384
+
385
+ export type PushIdentity = {
386
+ /**
387
+ * verified: the forge says the credential is the workspace account.
388
+ * no_account: the workspace names no vcs.account, so there is nothing to check.
389
+ * skipped: the check could not run (forge down, slow, unknown, misconfigured);
390
+ * git pushes anyway and `reason` says why.
391
+ */
392
+ status: "verified" | "no_account" | "skipped";
393
+ forge: "github" | "gitlab" | null;
394
+ account: string | null;
395
+ login: string | null;
396
+ reason?: string;
397
+ unblock?: string;
398
+ };
399
+
400
+ /**
401
+ * The best-effort identity check before `git push`: git decides the transport,
402
+ * so a forge that cannot answer (missing, slow, offline, unknown, another
403
+ * provider) pushes with `status: "skipped"` and the reason (S10, B1). Positive
404
+ * evidence still blocks: another account, no gh login for the workspace
405
+ * account, a broken token file or a rejected credential. All calls share one
406
+ * `budgetMs`; explicit credentials are memoized.
407
+ */
408
+ export function checkPushIdentity(
409
+ cwd: string,
410
+ options: Omit<ResolveOptions, "deadline"> & { budgetMs?: number } = {},
411
+ ): ForgeResult<PushIdentity> {
412
+ let workspace: ReturnType<typeof resolveRuntimeWorkspaceVcs>;
413
+ try {
414
+ workspace = resolveRuntimeWorkspaceVcs(cwd);
415
+ } catch (error) {
231
416
  return failure(
232
417
  "blocked",
233
- `identity_mismatch: the ${resolved.credential === "workspace_token_file" ? "vcs.tokenFile token" : `${bin} login`} is ${identity.data.login} but ${where} expects ${expectedAccount}`,
234
- resolved.credential === "workspace_token_file"
235
- ? `put a token for ${expectedAccount} in the ${where} vcs.tokenFile`
236
- : `${bin} auth login --hostname ${forge.apiHost} # sign in as ${expectedAccount}`,
418
+ error instanceof Error ? error.message : String(error),
419
+ "fix ~/.config/workit/workspaces.json (workit init → Workspaces)",
237
420
  );
238
421
  }
239
- return identity;
422
+ const account = workspace?.vcs?.account ?? null;
423
+ const skipped = (forge: PushIdentity["forge"], reason: string, unblock?: string) =>
424
+ success<PushIdentity>({
425
+ status: "skipped",
426
+ forge,
427
+ account,
428
+ login: null,
429
+ reason,
430
+ ...(unblock ? { unblock } : {}),
431
+ });
432
+ const pushed = pushForge(cwd, {
433
+ branch: options.branch,
434
+ hosts: configuredHosts(),
435
+ sshConfig: options.sshConfig,
436
+ });
437
+ if (!pushed.ok) {
438
+ const host = /^unsupported_forge: .*?"([^"]+)"/u.exec(pushed.error)?.[1];
439
+ const what = /could not resolve ssh alias/u.test(pushed.error)
440
+ ? `could not resolve ssh alias ${host} to GitHub or GitLab`
441
+ : pushed.error.startsWith("unsupported_forge: ssh host")
442
+ ? `ssh host ${host} is not a GitHub/GitLab host or an alias workit can resolve`
443
+ : `${host} is not a GitHub or GitLab host workit knows`;
444
+ return skipped(
445
+ null,
446
+ host
447
+ ? `unsupported_forge: ${what}; account checks skipped, pushed with git only (no PR or CI verbs)`
448
+ : pushed.error,
449
+ pushed.unblock,
450
+ );
451
+ }
452
+ const derived = pushed.forge;
453
+ const conflict = forgeConflict(derived, workspace?.vcs?.provider);
454
+ if (conflict) return skipped(derived.kind, conflict.error, conflict.unblock);
455
+ if (!account) return success({ status: "no_account", forge: derived.kind, account, login: null });
456
+
457
+ const base =
458
+ options.runner ?? systemRunner(derived.kind, derived.apiHost, options.env ?? process.env);
459
+ const scope = memoScope(options.runner, options.env ?? process.env);
460
+ const now = options.now ?? Date.now;
461
+ const limits = { deadline: now() + (options.budgetMs ?? PUSH_IDENTITY_BUDGET_MS) };
462
+ const credential = resolveCredential(workspace, derived, clampedRunner(base, limits, now), scope);
463
+ // A forge that cannot answer (missing CLI, timeout, transport) is skipped;
464
+ // local evidence against the account (no gh login for it, a broken
465
+ // vcs.tokenFile, a rejected credential) still blocks: over HTTPS git would
466
+ // push with whatever account gh's credential helper has active.
467
+ if (!credential.ok)
468
+ return unreachable(credential)
469
+ ? skipped(derived.kind, credential.error, credential.unblock)
470
+ : credential;
471
+ const forge = memoizedIdentity(
472
+ createForge(derived, derived.repo, clampedRunner(base, limits, now, credential.data.token)),
473
+ scope,
474
+ credential.data,
475
+ );
476
+ const identity = forge.identity(account);
477
+ if (!identity.ok)
478
+ return unreachable(identity)
479
+ ? skipped(derived.kind, identity.error, identity.unblock)
480
+ : identity;
481
+ if (identity.data.matches === false)
482
+ return mismatch(
483
+ { forge, credential: credential.data.credential, expectedAccount: account },
484
+ identity.data.login ?? "?",
485
+ workspace?.name ?? null,
486
+ );
487
+ if (identity.data.matches === null)
488
+ return skipped(derived.kind, identity.data.note ?? "the credential cannot read /user");
489
+ return success({ status: "verified", forge: derived.kind, account, login: identity.data.login });
240
490
  }