@tenderprompt/accounts 0.4.0 → 0.5.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.
package/dist/main.js CHANGED
@@ -1,19 +1,21 @@
1
1
  import { fileURLToPath } from "node:url";
2
2
  import { copyFile, mkdir, readFile } from "node:fs/promises";
3
3
  import { dirname, resolve } from "node:path";
4
- import { hostname } from "node:os";
4
+ import { homedir, 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
- export const CLI_VERSION = "0.4.0";
15
+ export const CLI_VERSION = "0.5.1";
15
16
  const DEFAULT_RUNTIME = {
16
17
  cwd: process.cwd(),
18
+ homeDirectory: homedir(),
17
19
  environment: process.env,
18
20
  stdin: process.stdin,
19
21
  stdout: process.stdout,
@@ -23,6 +25,7 @@ const DEFAULT_RUNTIME = {
23
25
  runProjectCommand,
24
26
  captureCommand,
25
27
  authStore: createDefaultAuthStore(process.env),
28
+ profileBindings: createDefaultProfileBindings(process.env),
26
29
  openBrowser: openSystemBrowser,
27
30
  waitForLoopbackAuthorization,
28
31
  openLogStream: (url, protocols) => new WebSocket(url, protocols),
@@ -91,7 +94,7 @@ async function execute(topic, options, cwd, runtime) {
91
94
  ? { value: { schema: "tender.accounts-skill/v1", content: await readSkill() } }
92
95
  : { raw: await readSkill() };
93
96
  case "skill install":
94
- return { value: await installSkill(cwd, options) };
97
+ return { value: await installSkill(cwd, options, runtime) };
95
98
  case "dev":
96
99
  return await runDev(cwd, options, runtime);
97
100
  case "check":
@@ -100,10 +103,20 @@ async function execute(topic, options, cwd, runtime) {
100
103
  return { value: await buildProject(cwd, options, runtime) };
101
104
  case "auth status":
102
105
  return { value: await authStatus(cwd, options, runtime) };
106
+ case "auth list":
107
+ return { value: await authList(runtime) };
108
+ case "auth create":
109
+ return { value: await authCreate(cwd, options, runtime) };
110
+ case "auth delete":
111
+ return { value: await authDelete(cwd, options, runtime) };
112
+ case "auth activate":
113
+ return { value: await authActivate(cwd, options, runtime) };
114
+ case "auth deactivate":
115
+ return { value: await authDeactivate(cwd, options, runtime) };
103
116
  case "auth login":
104
117
  return { value: await authLogin(cwd, options, runtime) };
105
118
  case "auth logout":
106
- return { value: await authLogout(options, runtime) };
119
+ return { value: await authLogout(cwd, options, runtime) };
107
120
  case "link":
108
121
  return { value: await linkProject(cwd, options, runtime) };
109
122
  case "doctor":
@@ -221,34 +234,68 @@ async function authStatus(cwd, options, runtime) {
221
234
  const context = await optionalContext(cwd);
222
235
  const resolved = await authenticatedClient(cwd, context?.link ?? null, options, runtime);
223
236
  const principal = await resolved.client.whoami();
237
+ if (resolved.profile)
238
+ await savePrincipalSummary(runtime.authStore, resolved.profile, principal);
224
239
  return {
225
240
  ...principal,
226
241
  authentication: {
227
242
  source: resolved.source,
228
243
  apiUrl: resolved.apiUrl,
229
244
  ...(resolved.accessTokenExpiresAt ? { accessTokenExpiresAt: resolved.accessTokenExpiresAt } : {}),
230
- ...(resolved.source === "login" ? { storage: runtime.authStore.description() } : {}),
245
+ ...(resolved.source === "login" ? { profile: resolved.profile, storage: runtime.authStore.description() } : {}),
231
246
  },
232
247
  };
233
248
  }
249
+ async function authList(runtime) {
250
+ const profiles = await runtime.authStore.list();
251
+ const bindings = await runtime.profileBindings.list();
252
+ return {
253
+ schema: "tender.accounts-auth-profiles/v1",
254
+ storage: runtime.authStore.description(),
255
+ bindingStorage: runtime.profileBindings.description(),
256
+ profiles: profiles.map(({ profile, auth }) => ({
257
+ profile,
258
+ default: profile === DEFAULT_AUTH_PROFILE,
259
+ apiUrl: auth.apiUrl,
260
+ status: auth.pendingDevice ? "authorization_pending" : "authenticated",
261
+ ...(auth.pendingDevice ? { expiresAt: auth.pendingDevice.expiresAt } : {}),
262
+ ...(!auth.pendingDevice && auth.refreshTokenExpiresAt ? { expiresAt: auth.refreshTokenExpiresAt } : {}),
263
+ boundDirectories: bindings
264
+ .filter((binding) => binding.profile === profile)
265
+ .map((binding) => binding.directory),
266
+ containsCredential: false,
267
+ })),
268
+ };
269
+ }
234
270
  async function authLogin(cwd, options, runtime) {
271
+ return await authenticateProfile(cwd, options, runtime, DEFAULT_AUTH_PROFILE, options.booleans.has("force"));
272
+ }
273
+ async function authCreate(cwd, options, runtime) {
274
+ assertNoEnvironmentCredentialForProfileManagement(runtime.environment);
275
+ const profile = validateNamedAuthProfile(requiredPositional(options, 0, "profile name"));
276
+ return await authenticateProfile(cwd, options, runtime, profile, true);
277
+ }
278
+ async function authenticateProfile(cwd, options, runtime, profile, replaceExisting) {
235
279
  const context = await optionalContext(cwd);
236
280
  const apiUrl = selectedApiUrl(options, context?.link ?? null, runtime.environment);
237
281
  const projectHint = options.flags.get("project") ?? context?.link?.projectId;
238
282
  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")) {
283
+ const existing = await runtime.authStore.read(profile);
284
+ if (existing && !replaceExisting) {
285
+ const replacement = profile === DEFAULT_AUTH_PROFILE
286
+ ? "tender-accounts auth login --force"
287
+ : `tender-accounts auth create ${profile}`;
241
288
  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.");
289
+ ? `Tender CLI profile ${profile} already has a pending device authorization.`
290
+ : `Tender CLI profile ${profile} is already authenticated.`, existing.pendingDevice
291
+ ? `Run tender-accounts auth status --profile ${profile} --json after approving it, or ${replacement} to replace it.`
292
+ : `Run tender-accounts auth status --profile ${profile} --json, or ${replacement} to replace it.`);
246
293
  }
247
294
  if (existing?.refreshToken) {
248
295
  await new OAuthApiClient(existing.apiUrl, runtime.fetcher).revoke(existing.refreshToken);
249
296
  }
250
297
  if (existing)
251
- await runtime.authStore.clear();
298
+ await clearStoredProfile(runtime.authStore, profile, existing);
252
299
  const oauth = new OAuthApiClient(apiUrl, runtime.fetcher);
253
300
  if (options.booleans.has("device")) {
254
301
  const device = await oauth.deviceAuthorization({ clientName, ...(projectHint ? { projectHint } : {}) });
@@ -265,7 +312,8 @@ async function authLogin(cwd, options, runtime) {
265
312
  intervalSeconds: device.interval,
266
313
  },
267
314
  };
268
- await runtime.authStore.write(pending);
315
+ if (!await runtime.authStore.compareAndSwap(profile, null, pending))
316
+ throw authProfileChanged(profile);
269
317
  if (!options.booleans.has("no-open"))
270
318
  await runtime.openBrowser(device.verification_uri_complete);
271
319
  if (!options.booleans.has("wait")) {
@@ -275,13 +323,14 @@ async function authLogin(cwd, options, runtime) {
275
323
  userCode: device.user_code,
276
324
  verificationUrl: device.verification_uri,
277
325
  verificationUrlComplete: device.verification_uri_complete,
326
+ profile,
278
327
  expiresAt,
279
- next: "Approve the request, then run tender-accounts auth status --json.",
328
+ next: `Approve the request, then run tender-accounts auth status --profile ${profile} --json.`,
280
329
  };
281
330
  }
282
- const token = await waitForDeviceToken(oauth, pending, runtime);
283
- await saveTokenResponse(runtime.authStore, apiUrl, token);
284
- return await authenticatedLoginSummary(apiUrl, token, runtime);
331
+ const token = await waitForDeviceToken(oauth, pending, profile, runtime);
332
+ await saveTokenResponse(runtime.authStore, profile, apiUrl, token, oauth, pending);
333
+ return await authenticatedLoginSummary(apiUrl, token, profile, runtime);
285
334
  }
286
335
  const pkce = createPkcePair();
287
336
  const callback = await runtime.waitForLoopbackAuthorization({
@@ -305,32 +354,117 @@ async function authLogin(cwd, options, runtime) {
305
354
  codeVerifier: pkce.verifier,
306
355
  redirectUri: callback.redirectUri,
307
356
  });
308
- await saveTokenResponse(runtime.authStore, apiUrl, token);
309
- return await authenticatedLoginSummary(apiUrl, token, runtime);
357
+ await saveTokenResponse(runtime.authStore, profile, apiUrl, token, oauth, null);
358
+ return await authenticatedLoginSummary(apiUrl, token, profile, runtime);
310
359
  }
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() };
360
+ async function authActivate(cwd, options, runtime) {
361
+ assertNoEnvironmentCredentialForProfileManagement(runtime.environment);
362
+ const profile = validateNamedAuthProfile(requiredPositional(options, 0, "profile name"));
363
+ if (!await runtime.authStore.read(profile)) {
364
+ throw new CliError("auth_profile_not_found", `Tender Accounts auth profile ${profile} does not exist.`, `tender-accounts auth create ${profile}`);
315
365
  }
316
- const stored = await runtime.authStore.read();
366
+ const directory = resolve(cwd, options.positionals[1] ?? ".");
367
+ await runtime.profileBindings.activate(profile, directory);
368
+ return {
369
+ status: "activated",
370
+ profile,
371
+ directory,
372
+ inheritedByDescendants: true,
373
+ containsCredential: false,
374
+ };
375
+ }
376
+ async function authDeactivate(cwd, options, runtime) {
377
+ assertNoEnvironmentCredentialForProfileManagement(runtime.environment);
378
+ const directory = resolve(cwd, options.positionals[0] ?? ".");
379
+ const removed = await runtime.profileBindings.deactivate(directory);
380
+ const fallback = await runtime.profileBindings.resolve(directory);
381
+ return {
382
+ status: "deactivated",
383
+ profile: removed.profile,
384
+ directory: removed.directory,
385
+ activeProfile: fallback?.profile ?? (await runtime.authStore.read(DEFAULT_AUTH_PROFILE) ? DEFAULT_AUTH_PROFILE : null),
386
+ containsCredential: false,
387
+ };
388
+ }
389
+ async function authDelete(_cwd, options, runtime) {
390
+ assertNoEnvironmentCredentialForProfileManagement(runtime.environment);
391
+ const profile = validateNamedAuthProfile(requiredPositional(options, 0, "profile name"));
392
+ const stored = await runtime.authStore.read(profile);
317
393
  if (!stored)
318
- return { status: "not_authenticated", remoteSessionRevoked: false };
319
- if (stored.refreshToken) {
394
+ throw new CliError("auth_profile_not_found", `Tender Accounts auth profile ${profile} does not exist.`);
395
+ if (!options.booleans.has("local") && stored.refreshToken) {
320
396
  await new OAuthApiClient(stored.apiUrl, runtime.fetcher).revoke(stored.refreshToken);
321
397
  }
322
- await runtime.authStore.clear();
398
+ await clearStoredProfile(runtime.authStore, profile, stored);
399
+ const removedBindings = await runtime.profileBindings.removeProfile(profile);
400
+ return {
401
+ status: "deleted",
402
+ profile,
403
+ remoteSessionRevoked: !options.booleans.has("local") && Boolean(stored.refreshToken),
404
+ removedBindings,
405
+ storage: runtime.authStore.description(),
406
+ };
407
+ }
408
+ function assertNoEnvironmentCredentialForProfileManagement(environment) {
409
+ if (!environment.TENDER_ACCOUNTS_TOKEN)
410
+ return;
411
+ 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.");
412
+ }
413
+ async function authLogout(_cwd, options, runtime) {
414
+ if (options.booleans.has("all") && options.booleans.has("local")) {
415
+ await runtime.authStore.clearAll();
416
+ await runtime.profileBindings.clearAll();
417
+ return {
418
+ status: "logged_out",
419
+ profiles: "all",
420
+ remoteSessionRevoked: false,
421
+ remoteSessionsRevoked: 0,
422
+ storage: runtime.authStore.description(),
423
+ };
424
+ }
425
+ const profiles = await runtime.authStore.list();
426
+ if (profiles.length === 0)
427
+ return { status: "not_authenticated", remoteSessionRevoked: false };
428
+ if (!options.booleans.has("all") && !profiles.some(({ profile }) => profile === DEFAULT_AUTH_PROFILE)) {
429
+ return {
430
+ status: "not_authenticated",
431
+ profile: DEFAULT_AUTH_PROFILE,
432
+ namedProfilesRetained: profiles.length,
433
+ remoteSessionRevoked: false,
434
+ };
435
+ }
436
+ const selectedProfiles = options.booleans.has("all")
437
+ ? profiles.map(({ profile }) => profile)
438
+ : [DEFAULT_AUTH_PROFILE];
439
+ let revoked = 0;
440
+ for (const profile of selectedProfiles) {
441
+ const stored = await runtime.authStore.read(profile);
442
+ if (!stored)
443
+ continue;
444
+ if (!options.booleans.has("local") && stored.refreshToken) {
445
+ await new OAuthApiClient(stored.apiUrl, runtime.fetcher).revoke(stored.refreshToken);
446
+ revoked += 1;
447
+ }
448
+ await clearStoredProfile(runtime.authStore, profile, stored);
449
+ }
450
+ if (options.booleans.has("all"))
451
+ await runtime.profileBindings.clearAll();
323
452
  return {
324
453
  status: "logged_out",
325
- remoteSessionRevoked: Boolean(stored.refreshToken),
454
+ profiles: selectedProfiles,
455
+ remoteSessionRevoked: revoked > 0,
456
+ remoteSessionsRevoked: revoked,
326
457
  storage: runtime.authStore.description(),
327
458
  };
328
459
  }
329
460
  async function linkProject(cwd, options, runtime) {
330
461
  const context = await loadProjectContext(cwd);
331
462
  const apiUrl = selectedApiUrl(options, context.link, runtime.environment);
332
- const { client } = await authenticatedClient(context.root, context.link, options, runtime);
463
+ const resolved = await authenticatedClient(context.root, context.link, options, runtime);
464
+ const { client } = resolved;
333
465
  const principal = await client.whoami();
466
+ if (resolved.profile)
467
+ await savePrincipalSummary(runtime.authStore, resolved.profile, principal);
334
468
  const requested = options.flags.get("project");
335
469
  const project = requested
336
470
  ? principal.projects.find((candidate) => candidate.projectId === requiredProjectId(requested))
@@ -354,6 +488,7 @@ async function linkProject(cwd, options, runtime) {
354
488
  projectId: project.projectId,
355
489
  role: project.role,
356
490
  apiUrl,
491
+ ...(resolved.profile ? { activeProfile: resolved.profile } : {}),
357
492
  linkPath: path,
358
493
  containsCredential: false,
359
494
  reminder: "Keep .tender/ and .dev.vars out of Git.",
@@ -513,63 +648,85 @@ async function authenticatedClient(cwd, link, options, runtime) {
513
648
  : "dev-vars";
514
649
  return { client: new AgentApiClient(apiUrl, explicit, runtime.fetcher), apiUrl, source };
515
650
  }
516
- const stored = await resolveStoredLogin(apiUrl, runtime);
651
+ const profile = await selectAuthProfile(cwd, options, runtime);
652
+ const stored = await resolveStoredLogin(apiUrl, profile, runtime);
517
653
  return {
518
654
  client: new AgentApiClient(apiUrl, stored.accessToken, runtime.fetcher),
519
655
  apiUrl,
520
656
  source: "login",
657
+ profile,
521
658
  accessTokenExpiresAt: stored.accessTokenExpiresAt,
522
659
  };
523
660
  }
661
+ async function selectAuthProfile(cwd, options, runtime) {
662
+ const explicit = options.flags.get("profile") ?? runtime.environment.TENDER_ACCOUNTS_PROFILE;
663
+ if (explicit)
664
+ return validateAuthProfile(explicit);
665
+ const binding = await runtime.profileBindings.resolve(cwd);
666
+ if (binding)
667
+ return binding.profile;
668
+ const profiles = await runtime.authStore.list();
669
+ if (profiles.some(({ profile }) => profile === DEFAULT_AUTH_PROFILE))
670
+ return DEFAULT_AUTH_PROFILE;
671
+ if (profiles.length === 0) {
672
+ 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");
673
+ }
674
+ 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}.`);
675
+ }
524
676
  function selectedApiUrl(options, link, environment) {
525
677
  return normalizeApiUrl(options.flags.get("api-url")
526
678
  ?? environment.TENDER_ACCOUNTS_API_URL
527
679
  ?? link?.apiUrl
528
680
  ?? "https://agents.tenderprompt.dev");
529
681
  }
530
- async function resolveStoredLogin(apiUrl, runtime) {
531
- let stored = await runtime.authStore.read();
682
+ async function resolveStoredLogin(apiUrl, profile, runtime) {
683
+ let stored = await runtime.authStore.read(profile);
532
684
  if (!stored) {
533
- throw new CliError("authentication_required", "No Tender Accounts credential or CLI login was found.", "tender-accounts auth login");
685
+ throw new CliError("authentication_required", `Tender Accounts auth profile ${profile} is not logged in.`, authCommandForProfile(profile));
534
686
  }
535
687
  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`);
688
+ 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
689
  }
538
690
  const oauth = new OAuthApiClient(apiUrl, runtime.fetcher);
539
691
  if (stored.pendingDevice) {
540
692
  const pendingDevice = stored.pendingDevice;
541
693
  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");
694
+ await clearStoredProfile(runtime.authStore, profile, stored);
695
+ throw new CliError("device_authorization_expired", "The pending device authorization expired.", `${authCommandForProfile(profile)} --device --json`);
544
696
  }
545
697
  try {
546
698
  const token = await oauth.exchangeDeviceCode(pendingDevice.deviceCode);
547
- stored = await saveTokenResponse(runtime.authStore, apiUrl, token);
699
+ stored = await saveTokenResponse(runtime.authStore, profile, apiUrl, token, oauth, stored);
548
700
  }
549
701
  catch (error) {
550
702
  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.`);
703
+ 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
704
  }
553
705
  throw error;
554
706
  }
555
707
  }
556
708
  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");
709
+ throw new CliError("authentication_required", "The stored Tender Accounts login is incomplete.", `${authCommandForProfile(profile)}${profile === DEFAULT_AUTH_PROFILE ? " --force" : ""}`);
558
710
  }
559
711
  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");
712
+ await clearStoredProfile(runtime.authStore, profile, stored);
713
+ throw new CliError("authentication_expired", "The Tender Accounts login expired.", authCommandForProfile(profile));
562
714
  }
563
715
  if (Date.parse(stored.accessTokenExpiresAt) <= Date.now() + 30_000) {
564
716
  const token = await oauth.refresh(stored.refreshToken);
565
- stored = await saveTokenResponse(runtime.authStore, apiUrl, token);
717
+ stored = await saveTokenResponse(runtime.authStore, profile, apiUrl, token, oauth, stored);
566
718
  }
567
719
  if (!stored.accessToken || !stored.accessTokenExpiresAt) {
568
- throw new CliError("authentication_required", "Tender Accounts could not restore the stored login.", "tender-accounts auth login --force");
720
+ throw new CliError("authentication_required", "Tender Accounts could not restore the stored login.", `${authCommandForProfile(profile)}${profile === DEFAULT_AUTH_PROFILE ? " --force" : ""}`);
569
721
  }
570
722
  return { accessToken: stored.accessToken, accessTokenExpiresAt: stored.accessTokenExpiresAt };
571
723
  }
572
- async function saveTokenResponse(store, apiUrl, token) {
724
+ function authCommandForProfile(profile) {
725
+ return profile === DEFAULT_AUTH_PROFILE
726
+ ? "tender-accounts auth login"
727
+ : `tender-accounts auth create ${profile}`;
728
+ }
729
+ async function saveTokenResponse(store, profile, apiUrl, token, oauth, previous) {
573
730
  const now = Date.now();
574
731
  const stored = {
575
732
  schema: CLI_AUTH_SCHEMA,
@@ -578,11 +735,27 @@ async function saveTokenResponse(store, apiUrl, token) {
578
735
  accessTokenExpiresAt: new Date(now + token.expires_in * 1000).toISOString(),
579
736
  refreshToken: token.refresh_token,
580
737
  refreshTokenExpiresAt: new Date(now + token.refresh_expires_in * 1000).toISOString(),
738
+ ...(previous?.principal ? { principal: previous.principal } : {}),
581
739
  };
582
- await store.write(stored);
740
+ if (!await store.compareAndSwap(profile, previous, stored)) {
741
+ try {
742
+ await oauth.revoke(token.refresh_token);
743
+ }
744
+ catch {
745
+ 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.`);
746
+ }
747
+ throw authProfileChanged(profile);
748
+ }
583
749
  return stored;
584
750
  }
585
- async function waitForDeviceToken(oauth, pending, runtime) {
751
+ async function clearStoredProfile(store, profile, expected) {
752
+ if (!await store.compareAndSwap(profile, expected, null))
753
+ throw authProfileChanged(profile);
754
+ }
755
+ function authProfileChanged(profile) {
756
+ 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.`);
757
+ }
758
+ async function waitForDeviceToken(oauth, pending, profile, runtime) {
586
759
  const device = pending.pendingDevice;
587
760
  if (!device)
588
761
  throw new CliError("device_authorization_missing", "The device authorization was not saved.");
@@ -596,15 +769,17 @@ async function waitForDeviceToken(oauth, pending, runtime) {
596
769
  await runtime.sleep(device.intervalSeconds * 1000);
597
770
  }
598
771
  }
599
- await runtime.authStore.clear();
600
- throw new CliError("device_authorization_expired", "The device authorization expired.", "tender-accounts auth login --device --json");
772
+ await clearStoredProfile(runtime.authStore, profile, pending);
773
+ throw new CliError("device_authorization_expired", "The device authorization expired.", `${authCommandForProfile(profile)} --device --json`);
601
774
  }
602
- async function authenticatedLoginSummary(apiUrl, token, runtime) {
775
+ async function authenticatedLoginSummary(apiUrl, token, profile, runtime) {
603
776
  const principal = await new AgentApiClient(apiUrl, token.access_token, runtime.fetcher).whoami();
777
+ await savePrincipalSummary(runtime.authStore, profile, principal);
604
778
  return {
605
779
  status: "authenticated",
606
780
  flow: "oauth",
607
781
  apiUrl,
782
+ profile,
608
783
  ownerEmail: principal.ownerEmail,
609
784
  tenantId: principal.tenantId,
610
785
  projects: principal.projects,
@@ -613,6 +788,21 @@ async function authenticatedLoginSummary(apiUrl, token, runtime) {
613
788
  containsCredential: false,
614
789
  };
615
790
  }
791
+ async function savePrincipalSummary(store, profile, principal) {
792
+ const stored = await store.read(profile);
793
+ if (!stored)
794
+ return;
795
+ const updated = {
796
+ ...stored,
797
+ principal: {
798
+ tenantId: principal.tenantId,
799
+ ownerEmail: principal.ownerEmail,
800
+ projects: principal.projects.map(({ projectId }) => projectId),
801
+ },
802
+ };
803
+ if (!await store.compareAndSwap(profile, stored, updated))
804
+ throw authProfileChanged(profile);
805
+ }
616
806
  async function sourceState(root, runtime) {
617
807
  let revision;
618
808
  try {
@@ -679,15 +869,15 @@ async function optionalContext(cwd) {
679
869
  throw error;
680
870
  }
681
871
  }
682
- async function installSkill(cwd, options) {
872
+ async function installSkill(cwd, options, runtime) {
683
873
  const agent = options.flags.get("agent") ?? "codex";
684
874
  const requestedDirectory = options.flags.get("directory");
685
875
  const base = requestedDirectory
686
876
  ? resolve(cwd, requestedDirectory)
687
877
  : agent === "codex"
688
- ? resolve(cwd, ".agents/skills")
878
+ ? resolve(runtime.homeDirectory, ".agents/skills")
689
879
  : agent === "claude"
690
- ? resolve(cwd, ".claude/skills")
880
+ ? resolve(runtime.homeDirectory, ".claude/skills")
691
881
  : null;
692
882
  if (!base) {
693
883
  throw new CliError("skill_target_invalid", "--agent must be codex or claude unless --directory is provided.");
@@ -745,14 +935,18 @@ function commandTopic(argv) {
745
935
  function parseOptions(values) {
746
936
  const flags = new Map();
747
937
  const booleans = new Set();
938
+ const positionals = [];
748
939
  const booleanNames = new Set([
749
940
  "json", "token-stdin", "dry-run", "no-wait", "force",
750
- "device", "wait", "no-open", "local", "production",
941
+ "device", "wait", "no-open", "local", "all", "production",
751
942
  ]);
752
943
  for (let index = 0; index < values.length; index += 1) {
753
944
  const raw = values[index];
754
- if (!raw?.startsWith("--"))
755
- throw new CliError("argument_unexpected", `Unexpected argument: ${raw ?? ""}.`);
945
+ if (!raw?.startsWith("--")) {
946
+ if (raw)
947
+ positionals.push(raw);
948
+ continue;
949
+ }
756
950
  const name = raw.slice(2);
757
951
  if (name === "token") {
758
952
  throw new CliError("credential_argument_forbidden", "Never pass credentials as command-line arguments.");
@@ -770,11 +964,11 @@ function parseOptions(values) {
770
964
  flags.set(name, value);
771
965
  index += 1;
772
966
  }
773
- return { flags, booleans, json: booleans.has("json") };
967
+ return { flags, booleans, positionals, json: booleans.has("json") };
774
968
  }
775
969
  function assertAllowedOptions(topic, options) {
776
970
  const common = ["cwd", "json"];
777
- const authentication = ["api-url", "token-stdin"];
971
+ const authentication = ["api-url", "token-stdin", "profile"];
778
972
  const allowed = {
779
973
  init: [...common, "name", "slug", "directory", "template", "dry-run"],
780
974
  "config example": common,
@@ -784,8 +978,13 @@ function assertAllowedOptions(topic, options) {
784
978
  check: common,
785
979
  build: common,
786
980
  "auth status": [...common, ...authentication],
981
+ "auth list": common,
982
+ "auth create": [...common, "api-url", "project", "client-name", "timeout-seconds", "device", "wait", "no-open"],
983
+ "auth delete": [...common, "local"],
984
+ "auth activate": common,
985
+ "auth deactivate": common,
787
986
  "auth login": [...common, "api-url", "project", "client-name", "timeout-seconds", "device", "wait", "no-open", "force"],
788
- "auth logout": [...common, "local"],
987
+ "auth logout": [...common, "local", "all"],
789
988
  link: [...common, ...authentication, "project"],
790
989
  doctor: [...common, ...authentication],
791
990
  preview: [
@@ -818,6 +1017,25 @@ function assertAllowedOptions(topic, options) {
818
1017
  if (unexpected) {
819
1018
  throw new CliError("argument_unknown", `Option --${unexpected} is not valid for ${topic}.`, `tender-accounts ${topic} --help`);
820
1019
  }
1020
+ const positionalLimits = {
1021
+ "auth create": { minimum: 1, maximum: 1 },
1022
+ "auth delete": { minimum: 1, maximum: 1 },
1023
+ "auth activate": { minimum: 1, maximum: 2 },
1024
+ "auth deactivate": { minimum: 0, maximum: 1 },
1025
+ };
1026
+ const limit = positionalLimits[topic] ?? { minimum: 0, maximum: 0 };
1027
+ if (options.positionals.length < limit.minimum) {
1028
+ throw new CliError("argument_required", `Missing ${topic.startsWith("auth ") ? "profile name" : "argument"}.`, `tender-accounts ${topic} --help`);
1029
+ }
1030
+ if (options.positionals.length > limit.maximum) {
1031
+ throw new CliError("argument_unexpected", `Unexpected argument: ${options.positionals[limit.maximum] ?? ""}.`, `tender-accounts ${topic} --help`);
1032
+ }
1033
+ }
1034
+ function requiredPositional(options, index, label) {
1035
+ const value = options.positionals[index];
1036
+ if (!value)
1037
+ throw new CliError("argument_required", `Missing ${label}.`);
1038
+ return value;
821
1039
  }
822
1040
  function requiredFlag(options, name) {
823
1041
  const value = options.flags.get(name);
@@ -894,11 +1112,16 @@ function writeOutput(value, options, runtime) {
894
1112
  function helpText(topic) {
895
1113
  const topics = {
896
1114
  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.",
1115
+ auth: "Usage: tender-accounts auth <login|create|activate|deactivate|status|list|delete|logout> [options]",
1116
+ "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.",
1117
+ "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",
1118
+ "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.",
1119
+ "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.",
1120
+ "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.",
1121
+ "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.",
1122
+ "auth list": "Usage: tender-accounts auth list [--json]\n\nList locally stored profile names, expiration state, and bound directories without credentials or tenant metadata.",
1123
+ "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.",
1124
+ 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
1125
  doctor: "Usage: tender-accounts doctor [--json]\n\nValidate configuration, authentication, project scope, and local artifact readiness.",
903
1126
  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
1127
  check: "Usage: tender-accounts check [--json]\n\nRun the app's declared validation command.",
@@ -911,13 +1134,13 @@ function helpText(topic) {
911
1134
  "delivery retry": "Usage: tender-accounts delivery retry --delivery dly_... [--json]\n\nRetry one requested or failed developer preview idempotently. A dwf_ production workflow is resumed by a merchant administrator from Activity.",
912
1135
  skill: "Usage: tender-accounts skill <print|install> [options]",
913
1136
  "skill print": "Usage: tender-accounts skill print [--json]",
914
- "skill install": "Usage: tender-accounts skill install [--agent codex|claude] [--directory PATH] [--force] [--json]",
1137
+ "skill install": "Usage: tender-accounts skill install [--agent codex|claude] [--directory PATH] [--force] [--json]\n\nInstall globally for the current user by default: ~/.agents/skills for Codex or ~/.claude/skills for Claude. --directory is an explicit custom or repository-local override.",
915
1138
  config: "Usage: tender-accounts config example [--json]",
916
1139
  "config example": "Usage: tender-accounts config example [--json]\n\nPrint the committed tender-accounts.json contract.",
917
1140
  };
918
1141
  if (topic && topics[topic])
919
1142
  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.`;
1143
+ 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
1144
  }
922
1145
  function normalizeError(error) {
923
1146
  if (error instanceof CliApiError) {