@tenderprompt/accounts 0.4.0 → 0.5.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.
package/dist/main.js CHANGED
@@ -3,13 +3,14 @@ import { copyFile, mkdir, readFile } from "node:fs/promises";
3
3
  import { dirname, resolve } from "node:path";
4
4
  import { hostname } from "node:os";
5
5
  import { AgentApiClient } from "./api.js";
6
- import { CLI_AUTH_SCHEMA, createDefaultAuthStore, } from "./auth-store.js";
6
+ import { CLI_AUTH_SCHEMA, DEFAULT_AUTH_PROFILE, createDefaultAuthStore, validateAuthProfile, validateNamedAuthProfile, } from "./auth-store.js";
7
7
  import { labelLocalArtifact, verifyArtifact } from "./artifact.js";
8
8
  import { BUILD_ENVELOPE_FILENAME, CLI_CONFIG_SCHEMA, CLI_LINK_SCHEMA, } from "./contracts.js";
9
9
  import { CliApiError, CliError } from "./errors.js";
10
10
  import { loadProjectContext, normalizeApiUrl, pathExists, readBoundedFile, readOptionalToken, requiredProjectId, resolveArtifactDirectory, safeArtifactPath, writeProjectLink, } from "./files.js";
11
11
  import { OAuthApiClient, createPkcePair, openSystemBrowser, waitForLoopbackAuthorization, } from "./oauth.js";
12
12
  import { captureCommand, runProjectCommand } from "./processes.js";
13
+ import { createDefaultProfileBindings, } from "./profile-bindings.js";
13
14
  import { initializeStarter } from "./starter.js";
14
15
  export const CLI_VERSION = "0.4.0";
15
16
  const DEFAULT_RUNTIME = {
@@ -23,6 +24,7 @@ const DEFAULT_RUNTIME = {
23
24
  runProjectCommand,
24
25
  captureCommand,
25
26
  authStore: createDefaultAuthStore(process.env),
27
+ profileBindings: createDefaultProfileBindings(process.env),
26
28
  openBrowser: openSystemBrowser,
27
29
  waitForLoopbackAuthorization,
28
30
  openLogStream: (url, protocols) => new WebSocket(url, protocols),
@@ -100,10 +102,20 @@ async function execute(topic, options, cwd, runtime) {
100
102
  return { value: await buildProject(cwd, options, runtime) };
101
103
  case "auth status":
102
104
  return { value: await authStatus(cwd, options, runtime) };
105
+ case "auth list":
106
+ return { value: await authList(runtime) };
107
+ case "auth create":
108
+ return { value: await authCreate(cwd, options, runtime) };
109
+ case "auth delete":
110
+ return { value: await authDelete(cwd, options, runtime) };
111
+ case "auth activate":
112
+ return { value: await authActivate(cwd, options, runtime) };
113
+ case "auth deactivate":
114
+ return { value: await authDeactivate(cwd, options, runtime) };
103
115
  case "auth login":
104
116
  return { value: await authLogin(cwd, options, runtime) };
105
117
  case "auth logout":
106
- return { value: await authLogout(options, runtime) };
118
+ return { value: await authLogout(cwd, options, runtime) };
107
119
  case "link":
108
120
  return { value: await linkProject(cwd, options, runtime) };
109
121
  case "doctor":
@@ -221,34 +233,68 @@ async function authStatus(cwd, options, runtime) {
221
233
  const context = await optionalContext(cwd);
222
234
  const resolved = await authenticatedClient(cwd, context?.link ?? null, options, runtime);
223
235
  const principal = await resolved.client.whoami();
236
+ if (resolved.profile)
237
+ await savePrincipalSummary(runtime.authStore, resolved.profile, principal);
224
238
  return {
225
239
  ...principal,
226
240
  authentication: {
227
241
  source: resolved.source,
228
242
  apiUrl: resolved.apiUrl,
229
243
  ...(resolved.accessTokenExpiresAt ? { accessTokenExpiresAt: resolved.accessTokenExpiresAt } : {}),
230
- ...(resolved.source === "login" ? { storage: runtime.authStore.description() } : {}),
244
+ ...(resolved.source === "login" ? { profile: resolved.profile, storage: runtime.authStore.description() } : {}),
231
245
  },
232
246
  };
233
247
  }
248
+ async function authList(runtime) {
249
+ const profiles = await runtime.authStore.list();
250
+ const bindings = await runtime.profileBindings.list();
251
+ return {
252
+ schema: "tender.accounts-auth-profiles/v1",
253
+ storage: runtime.authStore.description(),
254
+ bindingStorage: runtime.profileBindings.description(),
255
+ profiles: profiles.map(({ profile, auth }) => ({
256
+ profile,
257
+ default: profile === DEFAULT_AUTH_PROFILE,
258
+ apiUrl: auth.apiUrl,
259
+ status: auth.pendingDevice ? "authorization_pending" : "authenticated",
260
+ ...(auth.pendingDevice ? { expiresAt: auth.pendingDevice.expiresAt } : {}),
261
+ ...(!auth.pendingDevice && auth.refreshTokenExpiresAt ? { expiresAt: auth.refreshTokenExpiresAt } : {}),
262
+ boundDirectories: bindings
263
+ .filter((binding) => binding.profile === profile)
264
+ .map((binding) => binding.directory),
265
+ containsCredential: false,
266
+ })),
267
+ };
268
+ }
234
269
  async function authLogin(cwd, options, runtime) {
270
+ return await authenticateProfile(cwd, options, runtime, DEFAULT_AUTH_PROFILE, options.booleans.has("force"));
271
+ }
272
+ async function authCreate(cwd, options, runtime) {
273
+ assertNoEnvironmentCredentialForProfileManagement(runtime.environment);
274
+ const profile = validateNamedAuthProfile(requiredPositional(options, 0, "profile name"));
275
+ return await authenticateProfile(cwd, options, runtime, profile, true);
276
+ }
277
+ async function authenticateProfile(cwd, options, runtime, profile, replaceExisting) {
235
278
  const context = await optionalContext(cwd);
236
279
  const apiUrl = selectedApiUrl(options, context?.link ?? null, runtime.environment);
237
280
  const projectHint = options.flags.get("project") ?? context?.link?.projectId;
238
281
  const clientName = options.flags.get("client-name") ?? `Tender CLI on ${hostname()}`;
239
- const existing = await runtime.authStore.read();
240
- if (existing && !options.booleans.has("force")) {
282
+ const existing = await runtime.authStore.read(profile);
283
+ if (existing && !replaceExisting) {
284
+ const replacement = profile === DEFAULT_AUTH_PROFILE
285
+ ? "tender-accounts auth login --force"
286
+ : `tender-accounts auth create ${profile}`;
241
287
  throw new CliError("already_authenticated", existing.pendingDevice
242
- ? "A Tender CLI device authorization is already pending."
243
- : "This machine already has a Tender Accounts login.", existing.pendingDevice
244
- ? "Run tender-accounts auth status --json after approving it, or auth login --force to replace it."
245
- : "Run tender-accounts auth status --json, or auth login --force to replace it.");
288
+ ? `Tender CLI profile ${profile} already has a pending device authorization.`
289
+ : `Tender CLI profile ${profile} is already authenticated.`, existing.pendingDevice
290
+ ? `Run tender-accounts auth status --profile ${profile} --json after approving it, or ${replacement} to replace it.`
291
+ : `Run tender-accounts auth status --profile ${profile} --json, or ${replacement} to replace it.`);
246
292
  }
247
293
  if (existing?.refreshToken) {
248
294
  await new OAuthApiClient(existing.apiUrl, runtime.fetcher).revoke(existing.refreshToken);
249
295
  }
250
296
  if (existing)
251
- await runtime.authStore.clear();
297
+ await clearStoredProfile(runtime.authStore, profile, existing);
252
298
  const oauth = new OAuthApiClient(apiUrl, runtime.fetcher);
253
299
  if (options.booleans.has("device")) {
254
300
  const device = await oauth.deviceAuthorization({ clientName, ...(projectHint ? { projectHint } : {}) });
@@ -265,7 +311,8 @@ async function authLogin(cwd, options, runtime) {
265
311
  intervalSeconds: device.interval,
266
312
  },
267
313
  };
268
- await runtime.authStore.write(pending);
314
+ if (!await runtime.authStore.compareAndSwap(profile, null, pending))
315
+ throw authProfileChanged(profile);
269
316
  if (!options.booleans.has("no-open"))
270
317
  await runtime.openBrowser(device.verification_uri_complete);
271
318
  if (!options.booleans.has("wait")) {
@@ -275,13 +322,14 @@ async function authLogin(cwd, options, runtime) {
275
322
  userCode: device.user_code,
276
323
  verificationUrl: device.verification_uri,
277
324
  verificationUrlComplete: device.verification_uri_complete,
325
+ profile,
278
326
  expiresAt,
279
- next: "Approve the request, then run tender-accounts auth status --json.",
327
+ next: `Approve the request, then run tender-accounts auth status --profile ${profile} --json.`,
280
328
  };
281
329
  }
282
- const token = await waitForDeviceToken(oauth, pending, runtime);
283
- await saveTokenResponse(runtime.authStore, apiUrl, token);
284
- return await authenticatedLoginSummary(apiUrl, token, runtime);
330
+ const token = await waitForDeviceToken(oauth, pending, profile, runtime);
331
+ await saveTokenResponse(runtime.authStore, profile, apiUrl, token, oauth, pending);
332
+ return await authenticatedLoginSummary(apiUrl, token, profile, runtime);
285
333
  }
286
334
  const pkce = createPkcePair();
287
335
  const callback = await runtime.waitForLoopbackAuthorization({
@@ -305,32 +353,117 @@ async function authLogin(cwd, options, runtime) {
305
353
  codeVerifier: pkce.verifier,
306
354
  redirectUri: callback.redirectUri,
307
355
  });
308
- await saveTokenResponse(runtime.authStore, apiUrl, token);
309
- return await authenticatedLoginSummary(apiUrl, token, runtime);
356
+ await saveTokenResponse(runtime.authStore, profile, apiUrl, token, oauth, null);
357
+ return await authenticatedLoginSummary(apiUrl, token, profile, runtime);
310
358
  }
311
- async function authLogout(options, runtime) {
312
- if (options.booleans.has("local")) {
313
- await runtime.authStore.clear();
314
- return { status: "logged_out", remoteSessionRevoked: false, storage: runtime.authStore.description() };
359
+ async function authActivate(cwd, options, runtime) {
360
+ assertNoEnvironmentCredentialForProfileManagement(runtime.environment);
361
+ const profile = validateNamedAuthProfile(requiredPositional(options, 0, "profile name"));
362
+ if (!await runtime.authStore.read(profile)) {
363
+ throw new CliError("auth_profile_not_found", `Tender Accounts auth profile ${profile} does not exist.`, `tender-accounts auth create ${profile}`);
315
364
  }
316
- const stored = await runtime.authStore.read();
365
+ const directory = resolve(cwd, options.positionals[1] ?? ".");
366
+ await runtime.profileBindings.activate(profile, directory);
367
+ return {
368
+ status: "activated",
369
+ profile,
370
+ directory,
371
+ inheritedByDescendants: true,
372
+ containsCredential: false,
373
+ };
374
+ }
375
+ async function authDeactivate(cwd, options, runtime) {
376
+ assertNoEnvironmentCredentialForProfileManagement(runtime.environment);
377
+ const directory = resolve(cwd, options.positionals[0] ?? ".");
378
+ const removed = await runtime.profileBindings.deactivate(directory);
379
+ const fallback = await runtime.profileBindings.resolve(directory);
380
+ return {
381
+ status: "deactivated",
382
+ profile: removed.profile,
383
+ directory: removed.directory,
384
+ activeProfile: fallback?.profile ?? (await runtime.authStore.read(DEFAULT_AUTH_PROFILE) ? DEFAULT_AUTH_PROFILE : null),
385
+ containsCredential: false,
386
+ };
387
+ }
388
+ async function authDelete(_cwd, options, runtime) {
389
+ assertNoEnvironmentCredentialForProfileManagement(runtime.environment);
390
+ const profile = validateNamedAuthProfile(requiredPositional(options, 0, "profile name"));
391
+ const stored = await runtime.authStore.read(profile);
317
392
  if (!stored)
318
- return { status: "not_authenticated", remoteSessionRevoked: false };
319
- if (stored.refreshToken) {
393
+ throw new CliError("auth_profile_not_found", `Tender Accounts auth profile ${profile} does not exist.`);
394
+ if (!options.booleans.has("local") && stored.refreshToken) {
320
395
  await new OAuthApiClient(stored.apiUrl, runtime.fetcher).revoke(stored.refreshToken);
321
396
  }
322
- await runtime.authStore.clear();
397
+ await clearStoredProfile(runtime.authStore, profile, stored);
398
+ const removedBindings = await runtime.profileBindings.removeProfile(profile);
399
+ return {
400
+ status: "deleted",
401
+ profile,
402
+ remoteSessionRevoked: !options.booleans.has("local") && Boolean(stored.refreshToken),
403
+ removedBindings,
404
+ storage: runtime.authStore.description(),
405
+ };
406
+ }
407
+ function assertNoEnvironmentCredentialForProfileManagement(environment) {
408
+ if (!environment.TENDER_ACCOUNTS_TOKEN)
409
+ return;
410
+ throw new CliError("auth_profile_environment_override", "TENDER_ACCOUNTS_TOKEN overrides local auth profiles, so profile management is disabled for this command.", "Unset TENDER_ACCOUNTS_TOKEN, then retry the auth profile command.");
411
+ }
412
+ async function authLogout(_cwd, options, runtime) {
413
+ if (options.booleans.has("all") && options.booleans.has("local")) {
414
+ await runtime.authStore.clearAll();
415
+ await runtime.profileBindings.clearAll();
416
+ return {
417
+ status: "logged_out",
418
+ profiles: "all",
419
+ remoteSessionRevoked: false,
420
+ remoteSessionsRevoked: 0,
421
+ storage: runtime.authStore.description(),
422
+ };
423
+ }
424
+ const profiles = await runtime.authStore.list();
425
+ if (profiles.length === 0)
426
+ return { status: "not_authenticated", remoteSessionRevoked: false };
427
+ if (!options.booleans.has("all") && !profiles.some(({ profile }) => profile === DEFAULT_AUTH_PROFILE)) {
428
+ return {
429
+ status: "not_authenticated",
430
+ profile: DEFAULT_AUTH_PROFILE,
431
+ namedProfilesRetained: profiles.length,
432
+ remoteSessionRevoked: false,
433
+ };
434
+ }
435
+ const selectedProfiles = options.booleans.has("all")
436
+ ? profiles.map(({ profile }) => profile)
437
+ : [DEFAULT_AUTH_PROFILE];
438
+ let revoked = 0;
439
+ for (const profile of selectedProfiles) {
440
+ const stored = await runtime.authStore.read(profile);
441
+ if (!stored)
442
+ continue;
443
+ if (!options.booleans.has("local") && stored.refreshToken) {
444
+ await new OAuthApiClient(stored.apiUrl, runtime.fetcher).revoke(stored.refreshToken);
445
+ revoked += 1;
446
+ }
447
+ await clearStoredProfile(runtime.authStore, profile, stored);
448
+ }
449
+ if (options.booleans.has("all"))
450
+ await runtime.profileBindings.clearAll();
323
451
  return {
324
452
  status: "logged_out",
325
- remoteSessionRevoked: Boolean(stored.refreshToken),
453
+ profiles: selectedProfiles,
454
+ remoteSessionRevoked: revoked > 0,
455
+ remoteSessionsRevoked: revoked,
326
456
  storage: runtime.authStore.description(),
327
457
  };
328
458
  }
329
459
  async function linkProject(cwd, options, runtime) {
330
460
  const context = await loadProjectContext(cwd);
331
461
  const apiUrl = selectedApiUrl(options, context.link, runtime.environment);
332
- const { client } = await authenticatedClient(context.root, context.link, options, runtime);
462
+ const resolved = await authenticatedClient(context.root, context.link, options, runtime);
463
+ const { client } = resolved;
333
464
  const principal = await client.whoami();
465
+ if (resolved.profile)
466
+ await savePrincipalSummary(runtime.authStore, resolved.profile, principal);
334
467
  const requested = options.flags.get("project");
335
468
  const project = requested
336
469
  ? principal.projects.find((candidate) => candidate.projectId === requiredProjectId(requested))
@@ -354,6 +487,7 @@ async function linkProject(cwd, options, runtime) {
354
487
  projectId: project.projectId,
355
488
  role: project.role,
356
489
  apiUrl,
490
+ ...(resolved.profile ? { activeProfile: resolved.profile } : {}),
357
491
  linkPath: path,
358
492
  containsCredential: false,
359
493
  reminder: "Keep .tender/ and .dev.vars out of Git.",
@@ -513,63 +647,85 @@ async function authenticatedClient(cwd, link, options, runtime) {
513
647
  : "dev-vars";
514
648
  return { client: new AgentApiClient(apiUrl, explicit, runtime.fetcher), apiUrl, source };
515
649
  }
516
- const stored = await resolveStoredLogin(apiUrl, runtime);
650
+ const profile = await selectAuthProfile(cwd, options, runtime);
651
+ const stored = await resolveStoredLogin(apiUrl, profile, runtime);
517
652
  return {
518
653
  client: new AgentApiClient(apiUrl, stored.accessToken, runtime.fetcher),
519
654
  apiUrl,
520
655
  source: "login",
656
+ profile,
521
657
  accessTokenExpiresAt: stored.accessTokenExpiresAt,
522
658
  };
523
659
  }
660
+ async function selectAuthProfile(cwd, options, runtime) {
661
+ const explicit = options.flags.get("profile") ?? runtime.environment.TENDER_ACCOUNTS_PROFILE;
662
+ if (explicit)
663
+ return validateAuthProfile(explicit);
664
+ const binding = await runtime.profileBindings.resolve(cwd);
665
+ if (binding)
666
+ return binding.profile;
667
+ const profiles = await runtime.authStore.list();
668
+ if (profiles.some(({ profile }) => profile === DEFAULT_AUTH_PROFILE))
669
+ return DEFAULT_AUTH_PROFILE;
670
+ if (profiles.length === 0) {
671
+ throw new CliError("authentication_required", "No Tender Accounts credential or CLI login was found.", "tender-accounts auth login, or tender-accounts auth create merchant-name");
672
+ }
673
+ throw new CliError("auth_profile_required", "Named Tender Accounts logins are available, but this directory does not select one and no default login exists.", `Run tender-accounts auth list --json, then tender-accounts auth activate ${profiles[0].profile}.`);
674
+ }
524
675
  function selectedApiUrl(options, link, environment) {
525
676
  return normalizeApiUrl(options.flags.get("api-url")
526
677
  ?? environment.TENDER_ACCOUNTS_API_URL
527
678
  ?? link?.apiUrl
528
679
  ?? "https://agents.tenderprompt.dev");
529
680
  }
530
- async function resolveStoredLogin(apiUrl, runtime) {
531
- let stored = await runtime.authStore.read();
681
+ async function resolveStoredLogin(apiUrl, profile, runtime) {
682
+ let stored = await runtime.authStore.read(profile);
532
683
  if (!stored) {
533
- throw new CliError("authentication_required", "No Tender Accounts credential or CLI login was found.", "tender-accounts auth login");
684
+ throw new CliError("authentication_required", `Tender Accounts auth profile ${profile} is not logged in.`, authCommandForProfile(profile));
534
685
  }
535
686
  if (stored.apiUrl !== apiUrl) {
536
- throw new CliError("auth_origin_mismatch", `The stored login belongs to ${stored.apiUrl}, not ${apiUrl}.`, `tender-accounts auth login --api-url ${apiUrl} --force`);
687
+ throw new CliError("auth_origin_mismatch", `The stored login belongs to ${stored.apiUrl}, not ${apiUrl}.`, `${authCommandForProfile(profile)} --api-url ${apiUrl}${profile === DEFAULT_AUTH_PROFILE ? " --force" : ""}`);
537
688
  }
538
689
  const oauth = new OAuthApiClient(apiUrl, runtime.fetcher);
539
690
  if (stored.pendingDevice) {
540
691
  const pendingDevice = stored.pendingDevice;
541
692
  if (Date.parse(pendingDevice.expiresAt) <= Date.now()) {
542
- await runtime.authStore.clear();
543
- throw new CliError("device_authorization_expired", "The pending device authorization expired.", "tender-accounts auth login --device --json");
693
+ await clearStoredProfile(runtime.authStore, profile, stored);
694
+ throw new CliError("device_authorization_expired", "The pending device authorization expired.", `${authCommandForProfile(profile)} --device --json`);
544
695
  }
545
696
  try {
546
697
  const token = await oauth.exchangeDeviceCode(pendingDevice.deviceCode);
547
- stored = await saveTokenResponse(runtime.authStore, apiUrl, token);
698
+ stored = await saveTokenResponse(runtime.authStore, profile, apiUrl, token, oauth, stored);
548
699
  }
549
700
  catch (error) {
550
701
  if (error instanceof CliApiError && error.code === "authorization_pending") {
551
- throw new CliError("authorization_pending", `Approve Tender CLI with code ${pendingDevice.userCode}.`, `Open ${pendingDevice.verificationUriComplete}, then run tender-accounts auth status --json.`);
702
+ throw new CliError("authorization_pending", `Approve Tender CLI with code ${pendingDevice.userCode}.`, `Open ${pendingDevice.verificationUriComplete}, then run tender-accounts auth status --profile ${profile} --json.`);
552
703
  }
553
704
  throw error;
554
705
  }
555
706
  }
556
707
  if (!stored.accessToken || !stored.accessTokenExpiresAt || !stored.refreshToken || !stored.refreshTokenExpiresAt) {
557
- throw new CliError("authentication_required", "The stored Tender Accounts login is incomplete.", "tender-accounts auth login --force");
708
+ throw new CliError("authentication_required", "The stored Tender Accounts login is incomplete.", `${authCommandForProfile(profile)}${profile === DEFAULT_AUTH_PROFILE ? " --force" : ""}`);
558
709
  }
559
710
  if (Date.parse(stored.refreshTokenExpiresAt) <= Date.now()) {
560
- await runtime.authStore.clear();
561
- throw new CliError("authentication_expired", "The Tender Accounts login expired.", "tender-accounts auth login");
711
+ await clearStoredProfile(runtime.authStore, profile, stored);
712
+ throw new CliError("authentication_expired", "The Tender Accounts login expired.", authCommandForProfile(profile));
562
713
  }
563
714
  if (Date.parse(stored.accessTokenExpiresAt) <= Date.now() + 30_000) {
564
715
  const token = await oauth.refresh(stored.refreshToken);
565
- stored = await saveTokenResponse(runtime.authStore, apiUrl, token);
716
+ stored = await saveTokenResponse(runtime.authStore, profile, apiUrl, token, oauth, stored);
566
717
  }
567
718
  if (!stored.accessToken || !stored.accessTokenExpiresAt) {
568
- throw new CliError("authentication_required", "Tender Accounts could not restore the stored login.", "tender-accounts auth login --force");
719
+ throw new CliError("authentication_required", "Tender Accounts could not restore the stored login.", `${authCommandForProfile(profile)}${profile === DEFAULT_AUTH_PROFILE ? " --force" : ""}`);
569
720
  }
570
721
  return { accessToken: stored.accessToken, accessTokenExpiresAt: stored.accessTokenExpiresAt };
571
722
  }
572
- async function saveTokenResponse(store, apiUrl, token) {
723
+ function authCommandForProfile(profile) {
724
+ return profile === DEFAULT_AUTH_PROFILE
725
+ ? "tender-accounts auth login"
726
+ : `tender-accounts auth create ${profile}`;
727
+ }
728
+ async function saveTokenResponse(store, profile, apiUrl, token, oauth, previous) {
573
729
  const now = Date.now();
574
730
  const stored = {
575
731
  schema: CLI_AUTH_SCHEMA,
@@ -578,11 +734,27 @@ async function saveTokenResponse(store, apiUrl, token) {
578
734
  accessTokenExpiresAt: new Date(now + token.expires_in * 1000).toISOString(),
579
735
  refreshToken: token.refresh_token,
580
736
  refreshTokenExpiresAt: new Date(now + token.refresh_expires_in * 1000).toISOString(),
737
+ ...(previous?.principal ? { principal: previous.principal } : {}),
581
738
  };
582
- await store.write(stored);
739
+ if (!await store.compareAndSwap(profile, previous, stored)) {
740
+ try {
741
+ await oauth.revoke(token.refresh_token);
742
+ }
743
+ catch {
744
+ throw new CliError("auth_profile_race_revoke_failed", `Tender Accounts profile ${profile} changed while authentication was rotating, and the newly issued session could not be revoked safely.`, `Run tender-accounts auth status --profile ${profile} --json, then tender-accounts auth delete ${profile} if the session is not expected.`);
745
+ }
746
+ throw authProfileChanged(profile);
747
+ }
583
748
  return stored;
584
749
  }
585
- async function waitForDeviceToken(oauth, pending, runtime) {
750
+ async function clearStoredProfile(store, profile, expected) {
751
+ if (!await store.compareAndSwap(profile, expected, null))
752
+ throw authProfileChanged(profile);
753
+ }
754
+ function authProfileChanged(profile) {
755
+ return new CliError("auth_profile_changed", `Tender Accounts profile ${profile} changed while this command was running. No newer local login was overwritten.`, `Run tender-accounts auth status --profile ${profile} --json, then retry the command.`);
756
+ }
757
+ async function waitForDeviceToken(oauth, pending, profile, runtime) {
586
758
  const device = pending.pendingDevice;
587
759
  if (!device)
588
760
  throw new CliError("device_authorization_missing", "The device authorization was not saved.");
@@ -596,15 +768,17 @@ async function waitForDeviceToken(oauth, pending, runtime) {
596
768
  await runtime.sleep(device.intervalSeconds * 1000);
597
769
  }
598
770
  }
599
- await runtime.authStore.clear();
600
- throw new CliError("device_authorization_expired", "The device authorization expired.", "tender-accounts auth login --device --json");
771
+ await clearStoredProfile(runtime.authStore, profile, pending);
772
+ throw new CliError("device_authorization_expired", "The device authorization expired.", `${authCommandForProfile(profile)} --device --json`);
601
773
  }
602
- async function authenticatedLoginSummary(apiUrl, token, runtime) {
774
+ async function authenticatedLoginSummary(apiUrl, token, profile, runtime) {
603
775
  const principal = await new AgentApiClient(apiUrl, token.access_token, runtime.fetcher).whoami();
776
+ await savePrincipalSummary(runtime.authStore, profile, principal);
604
777
  return {
605
778
  status: "authenticated",
606
779
  flow: "oauth",
607
780
  apiUrl,
781
+ profile,
608
782
  ownerEmail: principal.ownerEmail,
609
783
  tenantId: principal.tenantId,
610
784
  projects: principal.projects,
@@ -613,6 +787,21 @@ async function authenticatedLoginSummary(apiUrl, token, runtime) {
613
787
  containsCredential: false,
614
788
  };
615
789
  }
790
+ async function savePrincipalSummary(store, profile, principal) {
791
+ const stored = await store.read(profile);
792
+ if (!stored)
793
+ return;
794
+ const updated = {
795
+ ...stored,
796
+ principal: {
797
+ tenantId: principal.tenantId,
798
+ ownerEmail: principal.ownerEmail,
799
+ projects: principal.projects.map(({ projectId }) => projectId),
800
+ },
801
+ };
802
+ if (!await store.compareAndSwap(profile, stored, updated))
803
+ throw authProfileChanged(profile);
804
+ }
616
805
  async function sourceState(root, runtime) {
617
806
  let revision;
618
807
  try {
@@ -745,14 +934,18 @@ function commandTopic(argv) {
745
934
  function parseOptions(values) {
746
935
  const flags = new Map();
747
936
  const booleans = new Set();
937
+ const positionals = [];
748
938
  const booleanNames = new Set([
749
939
  "json", "token-stdin", "dry-run", "no-wait", "force",
750
- "device", "wait", "no-open", "local", "production",
940
+ "device", "wait", "no-open", "local", "all", "production",
751
941
  ]);
752
942
  for (let index = 0; index < values.length; index += 1) {
753
943
  const raw = values[index];
754
- if (!raw?.startsWith("--"))
755
- throw new CliError("argument_unexpected", `Unexpected argument: ${raw ?? ""}.`);
944
+ if (!raw?.startsWith("--")) {
945
+ if (raw)
946
+ positionals.push(raw);
947
+ continue;
948
+ }
756
949
  const name = raw.slice(2);
757
950
  if (name === "token") {
758
951
  throw new CliError("credential_argument_forbidden", "Never pass credentials as command-line arguments.");
@@ -770,11 +963,11 @@ function parseOptions(values) {
770
963
  flags.set(name, value);
771
964
  index += 1;
772
965
  }
773
- return { flags, booleans, json: booleans.has("json") };
966
+ return { flags, booleans, positionals, json: booleans.has("json") };
774
967
  }
775
968
  function assertAllowedOptions(topic, options) {
776
969
  const common = ["cwd", "json"];
777
- const authentication = ["api-url", "token-stdin"];
970
+ const authentication = ["api-url", "token-stdin", "profile"];
778
971
  const allowed = {
779
972
  init: [...common, "name", "slug", "directory", "template", "dry-run"],
780
973
  "config example": common,
@@ -784,8 +977,13 @@ function assertAllowedOptions(topic, options) {
784
977
  check: common,
785
978
  build: common,
786
979
  "auth status": [...common, ...authentication],
980
+ "auth list": common,
981
+ "auth create": [...common, "api-url", "project", "client-name", "timeout-seconds", "device", "wait", "no-open"],
982
+ "auth delete": [...common, "local"],
983
+ "auth activate": common,
984
+ "auth deactivate": common,
787
985
  "auth login": [...common, "api-url", "project", "client-name", "timeout-seconds", "device", "wait", "no-open", "force"],
788
- "auth logout": [...common, "local"],
986
+ "auth logout": [...common, "local", "all"],
789
987
  link: [...common, ...authentication, "project"],
790
988
  doctor: [...common, ...authentication],
791
989
  preview: [
@@ -818,6 +1016,25 @@ function assertAllowedOptions(topic, options) {
818
1016
  if (unexpected) {
819
1017
  throw new CliError("argument_unknown", `Option --${unexpected} is not valid for ${topic}.`, `tender-accounts ${topic} --help`);
820
1018
  }
1019
+ const positionalLimits = {
1020
+ "auth create": { minimum: 1, maximum: 1 },
1021
+ "auth delete": { minimum: 1, maximum: 1 },
1022
+ "auth activate": { minimum: 1, maximum: 2 },
1023
+ "auth deactivate": { minimum: 0, maximum: 1 },
1024
+ };
1025
+ const limit = positionalLimits[topic] ?? { minimum: 0, maximum: 0 };
1026
+ if (options.positionals.length < limit.minimum) {
1027
+ throw new CliError("argument_required", `Missing ${topic.startsWith("auth ") ? "profile name" : "argument"}.`, `tender-accounts ${topic} --help`);
1028
+ }
1029
+ if (options.positionals.length > limit.maximum) {
1030
+ throw new CliError("argument_unexpected", `Unexpected argument: ${options.positionals[limit.maximum] ?? ""}.`, `tender-accounts ${topic} --help`);
1031
+ }
1032
+ }
1033
+ function requiredPositional(options, index, label) {
1034
+ const value = options.positionals[index];
1035
+ if (!value)
1036
+ throw new CliError("argument_required", `Missing ${label}.`);
1037
+ return value;
821
1038
  }
822
1039
  function requiredFlag(options, name) {
823
1040
  const value = options.flags.get(name);
@@ -894,11 +1111,16 @@ function writeOutput(value, options, runtime) {
894
1111
  function helpText(topic) {
895
1112
  const topics = {
896
1113
  init: "Usage: tender-accounts init --name NAME [--slug SLUG] [--directory PATH] [--template shopify-customer-account] [--dry-run] [--json]\n\nCreate a new merchant-owned Shopify gateway and React portal in an empty directory.\n\nExamples:\n tender-accounts init --name \"Acme account\" --directory ./acme-account\n tender-accounts init --name \"Acme account\" --directory ./acme-account --dry-run --json",
897
- auth: "Usage: tender-accounts auth <login|status|logout> [options]",
898
- "auth login": "Usage: tender-accounts auth login [--device] [--project PROJECT_ID] [--no-open] [--wait] [--force] [--json]\n\nSign in through Tender and authorize exact developer projects. Browser-capable terminals use Authorization Code with PKCE. --device returns an agent-safe approval URL and code; add --wait to keep polling.\n\nExamples:\n tender-accounts auth login\n tender-accounts auth login --device --json\n tender-accounts auth login --device --project prj_... --no-open --json",
899
- "auth status": "Usage: tender-accounts auth status [--api-url URL] [--token-stdin] [--json]\n\nValidate the current login or explicit project-scoped credential and list its permitted projects. A pending device login is exchanged after browser approval.",
900
- "auth logout": "Usage: tender-accounts auth logout [--local] [--json]\n\nRevoke the stored Tender CLI session and remove it from this machine. --local removes only the local copy.\n\nExamples:\n tender-accounts auth logout --json\n tender-accounts auth logout --local --json",
901
- link: "Usage: tender-accounts link [--project PROJECT_ID] [--api-url URL] [--json]\n\nLink this app checkout to exactly one project permitted by the current credential.",
1114
+ auth: "Usage: tender-accounts auth <login|create|activate|deactivate|status|list|delete|logout> [options]",
1115
+ "auth login": "Usage: tender-accounts auth login [--device] [--project PROJECT_ID] [--no-open] [--wait] [--force] [--json]\n\nCreate or refresh the default login. Browser-capable terminals use Authorization Code with PKCE. --device returns an agent-safe approval URL and code; add --wait to keep polling. Use auth create for named merchant profiles.",
1116
+ "auth create": "Usage: tender-accounts auth create NAME [--device] [--project PROJECT_ID] [--no-open] [--wait] [--json]\n\nCreate or re-authorize one named login without changing the active profile for any directory.\n\nExample:\n tender-accounts auth create acme --device --project prj_... --json",
1117
+ "auth activate": "Usage: tender-accounts auth activate NAME [DIRECTORY] [--json]\n\nActivate a named login for a directory and all descendants. A closer descendant binding wins. The binding is machine-local and is not written to the repository.",
1118
+ "auth deactivate": "Usage: tender-accounts auth deactivate [DIRECTORY] [--json]\n\nRemove the binding declared exactly at the directory. An inherited binding must be removed at the ancestor that declares it.",
1119
+ "auth delete": "Usage: tender-accounts auth delete NAME [--local] [--json]\n\nRevoke and delete one named login plus its directory bindings. --local keeps the remote session intact.",
1120
+ "auth status": "Usage: tender-accounts auth status [--profile NAME] [--api-url URL] [--token-stdin] [--json]\n\nValidate the selected login or explicit project-scoped credential and list its permitted projects. Selection order is explicit profile, nearest private directory binding, then default. Repository files cannot select a local profile.",
1121
+ "auth list": "Usage: tender-accounts auth list [--json]\n\nList locally stored profile names, expiration state, and bound directories without credentials or tenant metadata.",
1122
+ "auth logout": "Usage: tender-accounts auth logout [--all] [--local] [--json]\n\nRevoke the default login. --local removes only the local copy; --all must be explicit to remove every login and directory binding.",
1123
+ link: "Usage: tender-accounts link [--project PROJECT_ID] [--profile NAME] [--api-url URL] [--json]\n\nLink this checkout to exactly one project. The project ID remains a target guard; profile activation stays machine-local.",
902
1124
  doctor: "Usage: tender-accounts doctor [--json]\n\nValidate configuration, authentication, project scope, and local artifact readiness.",
903
1125
  dev: "Usage: tender-accounts dev\n\nRun the app's declared local development command. This starts local development and does not create a Tender deployment.",
904
1126
  check: "Usage: tender-accounts check [--json]\n\nRun the app's declared validation command.",
@@ -917,7 +1139,7 @@ function helpText(topic) {
917
1139
  };
918
1140
  if (topic && topics[topic])
919
1141
  return topics[topic];
920
- return `Tender Accounts merchant developer CLI ${CLI_VERSION}\n\nCommands:\n init Create a new Shopify account starter\n auth login Sign in and choose exact project access\n auth status Validate the current login or scoped credential\n auth logout Revoke the stored CLI login\n link Link this checkout to one permitted project\n doctor Check developer readiness\n dev Run the project-owned local dev command\n check Run project validation\n build Package and verify a portable artifact\n preview Build and deploy an exact preview\n tail Stream logs for one exact linked project\n delivery status Read preview workflow state\n delivery preview Mint a fresh preview session\n delivery retry Retry a failed preview workflow\n skill install Install the Tender Accounts agent skill\n config example Print the project configuration contract\n\nThere is intentionally no production publish command.\nRun tender-accounts <command> --help for examples.`;
1142
+ return `Tender Accounts merchant developer CLI ${CLI_VERSION}\n\nCommands:\n init Create a new Shopify account starter\n auth login Create or refresh the default login\n auth create Create or re-authorize a named login\n auth activate Bind a named login to a directory tree\n auth deactivate Remove an exact directory binding\n auth status Validate one login or scoped credential\n auth list List local login profiles and bindings\n auth delete Revoke one named login\n auth logout Revoke the default or every login\n link Link this checkout to one permitted project\n doctor Check developer readiness\n dev Run the project-owned local dev command\n check Run project validation\n build Package and verify a portable artifact\n preview Build and deploy an exact preview\n tail Stream logs for one exact linked project\n delivery status Read preview workflow state\n delivery preview Mint a fresh preview session\n delivery retry Retry a failed preview workflow\n skill install Install the Tender Accounts agent skill\n config example Print the project configuration contract\n\nThere is intentionally no production publish command.\nRun tender-accounts <command> --help for examples.`;
921
1143
  }
922
1144
  function normalizeError(error) {
923
1145
  if (error instanceof CliApiError) {