@tenderprompt/accounts 0.3.4 → 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.
Files changed (36) hide show
  1. package/README.md +44 -9
  2. package/dist/api.d.ts +12 -1
  3. package/dist/api.d.ts.map +1 -1
  4. package/dist/api.js +9 -0
  5. package/dist/api.js.map +1 -1
  6. package/dist/auth-store.d.ts +26 -7
  7. package/dist/auth-store.d.ts.map +1 -1
  8. package/dist/auth-store.js +165 -25
  9. package/dist/auth-store.js.map +1 -1
  10. package/dist/contracts.d.ts +13 -0
  11. package/dist/contracts.d.ts.map +1 -1
  12. package/dist/files.d.ts.map +1 -1
  13. package/dist/files.js +3 -0
  14. package/dist/files.js.map +1 -1
  15. package/dist/main.d.ts +17 -1
  16. package/dist/main.d.ts.map +1 -1
  17. package/dist/main.js +419 -60
  18. package/dist/main.js.map +1 -1
  19. package/dist/profile-bindings.d.ts +26 -0
  20. package/dist/profile-bindings.d.ts.map +1 -0
  21. package/dist/profile-bindings.js +214 -0
  22. package/dist/profile-bindings.js.map +1 -0
  23. package/dist/starter.d.ts.map +1 -1
  24. package/dist/starter.js +2 -1
  25. package/dist/starter.js.map +1 -1
  26. package/package.json +3 -1
  27. package/skill/tender-accounts/SKILL.md +108 -9
  28. package/templates/shopify-customer-account/AGENTS.md +1 -0
  29. package/templates/shopify-customer-account/README.md +5 -2
  30. package/templates/shopify-customer-account/apps/gateway/AGENTS.md +1 -0
  31. package/templates/shopify-customer-account/apps/gateway/dot.dev.vars.example +1 -1
  32. package/templates/shopify-customer-account/apps/gateway/src/portal-service.test.ts +59 -7
  33. package/templates/shopify-customer-account/apps/gateway/src/portal-service.ts +23 -3
  34. package/templates/shopify-customer-account/apps/portal/AGENTS.md +1 -0
  35. package/templates/shopify-customer-account/apps/portal/README.md +2 -0
  36. package/templates/shopify-customer-account/dot.dev.vars.example +1 -1
package/dist/main.js CHANGED
@@ -3,15 +3,16 @@ 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
- export const CLI_VERSION = "0.3.4";
15
+ export const CLI_VERSION = "0.4.0";
15
16
  const DEFAULT_RUNTIME = {
16
17
  cwd: process.cwd(),
17
18
  environment: process.env,
@@ -23,8 +24,18 @@ 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,
30
+ openLogStream: (url, protocols) => new WebSocket(url, protocols),
31
+ onInterrupt: (handler) => {
32
+ process.once("SIGINT", handler);
33
+ process.once("SIGTERM", handler);
34
+ return () => {
35
+ process.off("SIGINT", handler);
36
+ process.off("SIGTERM", handler);
37
+ };
38
+ },
28
39
  };
29
40
  export async function runCli(argv, overrides = {}) {
30
41
  const runtime = { ...DEFAULT_RUNTIME, ...overrides };
@@ -91,16 +102,29 @@ async function execute(topic, options, cwd, runtime) {
91
102
  return { value: await buildProject(cwd, options, runtime) };
92
103
  case "auth status":
93
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) };
94
115
  case "auth login":
95
116
  return { value: await authLogin(cwd, options, runtime) };
96
117
  case "auth logout":
97
- return { value: await authLogout(options, runtime) };
118
+ return { value: await authLogout(cwd, options, runtime) };
98
119
  case "link":
99
120
  return { value: await linkProject(cwd, options, runtime) };
100
121
  case "doctor":
101
122
  return await doctor(cwd, options, runtime);
102
123
  case "preview":
103
124
  return { value: await preview(cwd, options, runtime) };
125
+ case "tail":
126
+ await tail(cwd, options, runtime);
127
+ return {};
104
128
  case "delivery status":
105
129
  return { value: await deliveryStatus(cwd, options, runtime) };
106
130
  case "delivery preview":
@@ -111,38 +135,166 @@ async function execute(topic, options, cwd, runtime) {
111
135
  throw new CliError("command_unknown", `Unknown command: ${topic}.`, "tender-accounts --help");
112
136
  }
113
137
  }
138
+ async function tail(cwd, options, runtime) {
139
+ const context = await loadProjectContext(cwd);
140
+ if (!context.link) {
141
+ throw new CliError("project_not_linked", "This app is not linked to a Tender Accounts project.", "tender-accounts link --json");
142
+ }
143
+ const projectId = requiredProjectId(context.link.projectId);
144
+ const resolved = await authenticatedClient(cwd, context.link, options, runtime);
145
+ const production = options.booleans.has("production");
146
+ const deliveryId = options.flags.get("delivery");
147
+ if (production === Boolean(deliveryId)) {
148
+ throw new CliError("runtime_log_target_required", "Choose exactly one runtime log target: --delivery dly_... or --production.", "tender-accounts tail --delivery dly_... --json");
149
+ }
150
+ if (deliveryId && !/^dly_[A-Za-z0-9_-]{8,64}$/u.test(deliveryId)) {
151
+ throw new CliError("developer_delivery_id_required", "--delivery must be a dly_ developer-delivery ID.");
152
+ }
153
+ const filters = {
154
+ statuses: csvFlag(options, "status"),
155
+ methods: csvFlag(options, "method"),
156
+ search: options.flags.get("search") ?? null,
157
+ samplingRate: decimalFlag(options, "sampling-rate", 0, 1, 1),
158
+ };
159
+ const session = await resolved.client.createRuntimeLogSession(projectId, {
160
+ environment: production ? "production" : "preview",
161
+ ...(deliveryId ? { deliveryId } : {}),
162
+ filters,
163
+ });
164
+ const streamUrl = new URL(session.streamUrl);
165
+ streamUrl.protocol = streamUrl.protocol === "https:" ? "wss:" : "ws:";
166
+ runtime.stderr.write(`Streaming ${session.environment} logs for ${projectId} until ${session.expiresAt}. Press Ctrl+C to stop.\n`);
167
+ const socket = runtime.openLogStream(streamUrl.toString(), [session.protocol, `tender-auth.${session.streamToken}`]);
168
+ let interrupted = false;
169
+ const removeInterrupt = runtime.onInterrupt(() => {
170
+ interrupted = true;
171
+ socket.close(1000, "CLI interrupted");
172
+ });
173
+ try {
174
+ await new Promise((resolvePromise, rejectPromise) => {
175
+ socket.addEventListener("message", (event) => {
176
+ const raw = typeof event.data === "string" ? event.data : String(event.data);
177
+ if (options.json)
178
+ runtime.stdout.write(`${raw}\n`);
179
+ else
180
+ runtime.stdout.write(`${formatRuntimeLogEvent(raw)}\n`);
181
+ });
182
+ socket.addEventListener("close", (event) => {
183
+ if (!interrupted && event.code !== 1000) {
184
+ rejectPromise(new CliError("runtime_log_stream_closed", `Runtime log stream closed (${event.code}${event.reason ? `: ${event.reason}` : ""}).`));
185
+ }
186
+ else {
187
+ resolvePromise();
188
+ }
189
+ });
190
+ socket.addEventListener("error", () => rejectPromise(new CliError("runtime_log_stream_failed", "Tender could not connect to the runtime log stream.", "Run tender-accounts doctor --json, then start a new tail session.")));
191
+ });
192
+ }
193
+ finally {
194
+ removeInterrupt();
195
+ await resolved.client.closeRuntimeLogSession(session.id).catch(() => undefined);
196
+ }
197
+ }
198
+ function formatRuntimeLogEvent(raw) {
199
+ try {
200
+ const value = JSON.parse(raw);
201
+ if (!isRecord(value))
202
+ return terminalSafe(raw);
203
+ const event = isRecord(value.event) ? value.event : {};
204
+ const timestamp = typeof value.timestamp === "number" ? new Date(value.timestamp).toISOString() : "unknown-time";
205
+ const request = [event.method, event.url, event.status]
206
+ .filter((part) => part !== undefined)
207
+ .map((part) => terminalSafe(String(part)))
208
+ .join(" ");
209
+ const lines = [`${timestamp} ${terminalSafe(String(value.outcome ?? "unknown"))}${request ? ` ${request}` : ""}`];
210
+ if (Array.isArray(value.logs)) {
211
+ for (const log of value.logs) {
212
+ if (isRecord(log))
213
+ lines.push(` ${String(log.level ?? "log")}: ${JSON.stringify(log.message ?? [])}`);
214
+ }
215
+ }
216
+ if (Array.isArray(value.exceptions)) {
217
+ for (const exception of value.exceptions) {
218
+ if (isRecord(exception)) {
219
+ lines.push(` exception: ${terminalSafe(String(exception.name ?? "Error"))}: ${terminalSafe(String(exception.message ?? ""))}`);
220
+ }
221
+ }
222
+ }
223
+ return lines.join("\n");
224
+ }
225
+ catch {
226
+ return terminalSafe(raw);
227
+ }
228
+ }
229
+ function terminalSafe(value) {
230
+ return value.replace(/[\u0000-\u001f\u007f-\u009f]/gu, (character) => (`\\u${character.charCodeAt(0).toString(16).padStart(4, "0")}`));
231
+ }
114
232
  async function authStatus(cwd, options, runtime) {
115
233
  const context = await optionalContext(cwd);
116
234
  const resolved = await authenticatedClient(cwd, context?.link ?? null, options, runtime);
117
235
  const principal = await resolved.client.whoami();
236
+ if (resolved.profile)
237
+ await savePrincipalSummary(runtime.authStore, resolved.profile, principal);
118
238
  return {
119
239
  ...principal,
120
240
  authentication: {
121
241
  source: resolved.source,
122
242
  apiUrl: resolved.apiUrl,
123
243
  ...(resolved.accessTokenExpiresAt ? { accessTokenExpiresAt: resolved.accessTokenExpiresAt } : {}),
124
- ...(resolved.source === "login" ? { storage: runtime.authStore.description() } : {}),
244
+ ...(resolved.source === "login" ? { profile: resolved.profile, storage: runtime.authStore.description() } : {}),
125
245
  },
126
246
  };
127
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
+ }
128
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) {
129
278
  const context = await optionalContext(cwd);
130
279
  const apiUrl = selectedApiUrl(options, context?.link ?? null, runtime.environment);
131
280
  const projectHint = options.flags.get("project") ?? context?.link?.projectId;
132
281
  const clientName = options.flags.get("client-name") ?? `Tender CLI on ${hostname()}`;
133
- const existing = await runtime.authStore.read();
134
- 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}`;
135
287
  throw new CliError("already_authenticated", existing.pendingDevice
136
- ? "A Tender CLI device authorization is already pending."
137
- : "This machine already has a Tender Accounts login.", existing.pendingDevice
138
- ? "Run tender-accounts auth status --json after approving it, or auth login --force to replace it."
139
- : "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.`);
140
292
  }
141
293
  if (existing?.refreshToken) {
142
294
  await new OAuthApiClient(existing.apiUrl, runtime.fetcher).revoke(existing.refreshToken);
143
295
  }
144
296
  if (existing)
145
- await runtime.authStore.clear();
297
+ await clearStoredProfile(runtime.authStore, profile, existing);
146
298
  const oauth = new OAuthApiClient(apiUrl, runtime.fetcher);
147
299
  if (options.booleans.has("device")) {
148
300
  const device = await oauth.deviceAuthorization({ clientName, ...(projectHint ? { projectHint } : {}) });
@@ -159,7 +311,8 @@ async function authLogin(cwd, options, runtime) {
159
311
  intervalSeconds: device.interval,
160
312
  },
161
313
  };
162
- await runtime.authStore.write(pending);
314
+ if (!await runtime.authStore.compareAndSwap(profile, null, pending))
315
+ throw authProfileChanged(profile);
163
316
  if (!options.booleans.has("no-open"))
164
317
  await runtime.openBrowser(device.verification_uri_complete);
165
318
  if (!options.booleans.has("wait")) {
@@ -169,13 +322,14 @@ async function authLogin(cwd, options, runtime) {
169
322
  userCode: device.user_code,
170
323
  verificationUrl: device.verification_uri,
171
324
  verificationUrlComplete: device.verification_uri_complete,
325
+ profile,
172
326
  expiresAt,
173
- 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.`,
174
328
  };
175
329
  }
176
- const token = await waitForDeviceToken(oauth, pending, runtime);
177
- await saveTokenResponse(runtime.authStore, apiUrl, token);
178
- 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);
179
333
  }
180
334
  const pkce = createPkcePair();
181
335
  const callback = await runtime.waitForLoopbackAuthorization({
@@ -199,32 +353,117 @@ async function authLogin(cwd, options, runtime) {
199
353
  codeVerifier: pkce.verifier,
200
354
  redirectUri: callback.redirectUri,
201
355
  });
202
- await saveTokenResponse(runtime.authStore, apiUrl, token);
203
- return await authenticatedLoginSummary(apiUrl, token, runtime);
356
+ await saveTokenResponse(runtime.authStore, profile, apiUrl, token, oauth, null);
357
+ return await authenticatedLoginSummary(apiUrl, token, profile, runtime);
204
358
  }
205
- async function authLogout(options, runtime) {
206
- if (options.booleans.has("local")) {
207
- await runtime.authStore.clear();
208
- 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}`);
209
364
  }
210
- 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);
211
392
  if (!stored)
212
- return { status: "not_authenticated", remoteSessionRevoked: false };
213
- 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) {
214
395
  await new OAuthApiClient(stored.apiUrl, runtime.fetcher).revoke(stored.refreshToken);
215
396
  }
216
- 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();
217
451
  return {
218
452
  status: "logged_out",
219
- remoteSessionRevoked: Boolean(stored.refreshToken),
453
+ profiles: selectedProfiles,
454
+ remoteSessionRevoked: revoked > 0,
455
+ remoteSessionsRevoked: revoked,
220
456
  storage: runtime.authStore.description(),
221
457
  };
222
458
  }
223
459
  async function linkProject(cwd, options, runtime) {
224
460
  const context = await loadProjectContext(cwd);
225
461
  const apiUrl = selectedApiUrl(options, context.link, runtime.environment);
226
- 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;
227
464
  const principal = await client.whoami();
465
+ if (resolved.profile)
466
+ await savePrincipalSummary(runtime.authStore, resolved.profile, principal);
228
467
  const requested = options.flags.get("project");
229
468
  const project = requested
230
469
  ? principal.projects.find((candidate) => candidate.projectId === requiredProjectId(requested))
@@ -248,6 +487,7 @@ async function linkProject(cwd, options, runtime) {
248
487
  projectId: project.projectId,
249
488
  role: project.role,
250
489
  apiUrl,
490
+ ...(resolved.profile ? { activeProfile: resolved.profile } : {}),
251
491
  linkPath: path,
252
492
  containsCredential: false,
253
493
  reminder: "Keep .tender/ and .dev.vars out of Git.",
@@ -407,63 +647,85 @@ async function authenticatedClient(cwd, link, options, runtime) {
407
647
  : "dev-vars";
408
648
  return { client: new AgentApiClient(apiUrl, explicit, runtime.fetcher), apiUrl, source };
409
649
  }
410
- const stored = await resolveStoredLogin(apiUrl, runtime);
650
+ const profile = await selectAuthProfile(cwd, options, runtime);
651
+ const stored = await resolveStoredLogin(apiUrl, profile, runtime);
411
652
  return {
412
653
  client: new AgentApiClient(apiUrl, stored.accessToken, runtime.fetcher),
413
654
  apiUrl,
414
655
  source: "login",
656
+ profile,
415
657
  accessTokenExpiresAt: stored.accessTokenExpiresAt,
416
658
  };
417
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
+ }
418
675
  function selectedApiUrl(options, link, environment) {
419
676
  return normalizeApiUrl(options.flags.get("api-url")
420
677
  ?? environment.TENDER_ACCOUNTS_API_URL
421
678
  ?? link?.apiUrl
422
679
  ?? "https://agents.tenderprompt.dev");
423
680
  }
424
- async function resolveStoredLogin(apiUrl, runtime) {
425
- let stored = await runtime.authStore.read();
681
+ async function resolveStoredLogin(apiUrl, profile, runtime) {
682
+ let stored = await runtime.authStore.read(profile);
426
683
  if (!stored) {
427
- 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));
428
685
  }
429
686
  if (stored.apiUrl !== apiUrl) {
430
- 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" : ""}`);
431
688
  }
432
689
  const oauth = new OAuthApiClient(apiUrl, runtime.fetcher);
433
690
  if (stored.pendingDevice) {
434
691
  const pendingDevice = stored.pendingDevice;
435
692
  if (Date.parse(pendingDevice.expiresAt) <= Date.now()) {
436
- await runtime.authStore.clear();
437
- 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`);
438
695
  }
439
696
  try {
440
697
  const token = await oauth.exchangeDeviceCode(pendingDevice.deviceCode);
441
- stored = await saveTokenResponse(runtime.authStore, apiUrl, token);
698
+ stored = await saveTokenResponse(runtime.authStore, profile, apiUrl, token, oauth, stored);
442
699
  }
443
700
  catch (error) {
444
701
  if (error instanceof CliApiError && error.code === "authorization_pending") {
445
- 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.`);
446
703
  }
447
704
  throw error;
448
705
  }
449
706
  }
450
707
  if (!stored.accessToken || !stored.accessTokenExpiresAt || !stored.refreshToken || !stored.refreshTokenExpiresAt) {
451
- 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" : ""}`);
452
709
  }
453
710
  if (Date.parse(stored.refreshTokenExpiresAt) <= Date.now()) {
454
- await runtime.authStore.clear();
455
- 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));
456
713
  }
457
714
  if (Date.parse(stored.accessTokenExpiresAt) <= Date.now() + 30_000) {
458
715
  const token = await oauth.refresh(stored.refreshToken);
459
- stored = await saveTokenResponse(runtime.authStore, apiUrl, token);
716
+ stored = await saveTokenResponse(runtime.authStore, profile, apiUrl, token, oauth, stored);
460
717
  }
461
718
  if (!stored.accessToken || !stored.accessTokenExpiresAt) {
462
- 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" : ""}`);
463
720
  }
464
721
  return { accessToken: stored.accessToken, accessTokenExpiresAt: stored.accessTokenExpiresAt };
465
722
  }
466
- 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) {
467
729
  const now = Date.now();
468
730
  const stored = {
469
731
  schema: CLI_AUTH_SCHEMA,
@@ -472,11 +734,27 @@ async function saveTokenResponse(store, apiUrl, token) {
472
734
  accessTokenExpiresAt: new Date(now + token.expires_in * 1000).toISOString(),
473
735
  refreshToken: token.refresh_token,
474
736
  refreshTokenExpiresAt: new Date(now + token.refresh_expires_in * 1000).toISOString(),
737
+ ...(previous?.principal ? { principal: previous.principal } : {}),
475
738
  };
476
- 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
+ }
477
748
  return stored;
478
749
  }
479
- 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) {
480
758
  const device = pending.pendingDevice;
481
759
  if (!device)
482
760
  throw new CliError("device_authorization_missing", "The device authorization was not saved.");
@@ -490,15 +768,17 @@ async function waitForDeviceToken(oauth, pending, runtime) {
490
768
  await runtime.sleep(device.intervalSeconds * 1000);
491
769
  }
492
770
  }
493
- await runtime.authStore.clear();
494
- 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`);
495
773
  }
496
- async function authenticatedLoginSummary(apiUrl, token, runtime) {
774
+ async function authenticatedLoginSummary(apiUrl, token, profile, runtime) {
497
775
  const principal = await new AgentApiClient(apiUrl, token.access_token, runtime.fetcher).whoami();
776
+ await savePrincipalSummary(runtime.authStore, profile, principal);
498
777
  return {
499
778
  status: "authenticated",
500
779
  flow: "oauth",
501
780
  apiUrl,
781
+ profile,
502
782
  ownerEmail: principal.ownerEmail,
503
783
  tenantId: principal.tenantId,
504
784
  projects: principal.projects,
@@ -507,6 +787,21 @@ async function authenticatedLoginSummary(apiUrl, token, runtime) {
507
787
  containsCredential: false,
508
788
  };
509
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
+ }
510
805
  async function sourceState(root, runtime) {
511
806
  let revision;
512
807
  try {
@@ -639,14 +934,18 @@ function commandTopic(argv) {
639
934
  function parseOptions(values) {
640
935
  const flags = new Map();
641
936
  const booleans = new Set();
937
+ const positionals = [];
642
938
  const booleanNames = new Set([
643
939
  "json", "token-stdin", "dry-run", "no-wait", "force",
644
- "device", "wait", "no-open", "local",
940
+ "device", "wait", "no-open", "local", "all", "production",
645
941
  ]);
646
942
  for (let index = 0; index < values.length; index += 1) {
647
943
  const raw = values[index];
648
- if (!raw?.startsWith("--"))
649
- throw new CliError("argument_unexpected", `Unexpected argument: ${raw ?? ""}.`);
944
+ if (!raw?.startsWith("--")) {
945
+ if (raw)
946
+ positionals.push(raw);
947
+ continue;
948
+ }
650
949
  const name = raw.slice(2);
651
950
  if (name === "token") {
652
951
  throw new CliError("credential_argument_forbidden", "Never pass credentials as command-line arguments.");
@@ -664,11 +963,11 @@ function parseOptions(values) {
664
963
  flags.set(name, value);
665
964
  index += 1;
666
965
  }
667
- return { flags, booleans, json: booleans.has("json") };
966
+ return { flags, booleans, positionals, json: booleans.has("json") };
668
967
  }
669
968
  function assertAllowedOptions(topic, options) {
670
969
  const common = ["cwd", "json"];
671
- const authentication = ["api-url", "token-stdin"];
970
+ const authentication = ["api-url", "token-stdin", "profile"];
672
971
  const allowed = {
673
972
  init: [...common, "name", "slug", "directory", "template", "dry-run"],
674
973
  "config example": common,
@@ -678,8 +977,13 @@ function assertAllowedOptions(topic, options) {
678
977
  check: common,
679
978
  build: common,
680
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,
681
985
  "auth login": [...common, "api-url", "project", "client-name", "timeout-seconds", "device", "wait", "no-open", "force"],
682
- "auth logout": [...common, "local"],
986
+ "auth logout": [...common, "local", "all"],
683
987
  link: [...common, ...authentication, "project"],
684
988
  doctor: [...common, ...authentication],
685
989
  preview: [
@@ -692,6 +996,16 @@ function assertAllowedOptions(topic, options) {
692
996
  "timeout-seconds",
693
997
  "return-path",
694
998
  ],
999
+ tail: [
1000
+ ...common,
1001
+ ...authentication,
1002
+ "delivery",
1003
+ "production",
1004
+ "status",
1005
+ "method",
1006
+ "search",
1007
+ "sampling-rate",
1008
+ ],
695
1009
  "delivery status": [...common, ...authentication, "delivery"],
696
1010
  "delivery preview": [...common, ...authentication, "delivery", "return-path"],
697
1011
  "delivery retry": [...common, ...authentication, "delivery"],
@@ -702,6 +1016,25 @@ function assertAllowedOptions(topic, options) {
702
1016
  if (unexpected) {
703
1017
  throw new CliError("argument_unknown", `Option --${unexpected} is not valid for ${topic}.`, `tender-accounts ${topic} --help`);
704
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;
705
1038
  }
706
1039
  function requiredFlag(options, name) {
707
1040
  const value = options.flags.get(name);
@@ -737,6 +1070,26 @@ function numberFlag(options, name, minimum, maximum, fallback) {
737
1070
  }
738
1071
  return value;
739
1072
  }
1073
+ function decimalFlag(options, name, minimumExclusive, maximum, fallback) {
1074
+ const raw = options.flags.get(name);
1075
+ if (!raw)
1076
+ return fallback;
1077
+ const value = Number(raw);
1078
+ if (!Number.isFinite(value) || value <= minimumExclusive || value > maximum) {
1079
+ throw new CliError("argument_invalid", `--${name} must be greater than ${minimumExclusive} and at most ${maximum}.`);
1080
+ }
1081
+ return value;
1082
+ }
1083
+ function csvFlag(options, name) {
1084
+ const raw = options.flags.get(name);
1085
+ if (!raw)
1086
+ return [];
1087
+ const values = [...new Set(raw.split(",").map((value) => value.trim()).filter(Boolean))];
1088
+ if (values.length > 12 || values.some((value) => value.length > 32)) {
1089
+ throw new CliError("argument_invalid", `--${name} has too many or overly long values.`);
1090
+ }
1091
+ return values;
1092
+ }
740
1093
  function writeOutput(value, options, runtime) {
741
1094
  if (options.json) {
742
1095
  runtime.stdout.write(`${JSON.stringify(value)}\n`);
@@ -758,16 +1111,22 @@ function writeOutput(value, options, runtime) {
758
1111
  function helpText(topic) {
759
1112
  const topics = {
760
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",
761
- auth: "Usage: tender-accounts auth <login|status|logout> [options]",
762
- "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",
763
- "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.",
764
- "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",
765
- 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.",
766
1124
  doctor: "Usage: tender-accounts doctor [--json]\n\nValidate configuration, authentication, project scope, and local artifact readiness.",
767
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.",
768
1126
  check: "Usage: tender-accounts check [--json]\n\nRun the app's declared validation command.",
769
1127
  build: "Usage: tender-accounts build [--json]\n\nRun validation and packaging, then verify the complete portable artifact.",
770
1128
  preview: "Usage: tender-accounts preview [--dry-run] [--no-wait] [--timeout-seconds N] [--return-path /account] [--json]\n\nBuild and submit an exact preview. --dry-run validates locally without authentication or upload.",
1129
+ tail: "Usage: tender-accounts tail (--delivery dly_... | --production) [--status ok,error,canceled] [--method GET,POST] [--search TEXT] [--sampling-rate 0.25] [--json]\n\nStream sanitized logs for this exact linked project. Preview tails require a succeeded dly_ delivery. Production tails require merchant-administrator authorization. Sessions expire after 15 minutes and do not persist events.",
771
1130
  delivery: "Usage: tender-accounts delivery <status|preview|retry> --delivery dly_... [--json]\n\nThese commands accept the developer-delivery ID returned by preview. A dwf_ ID is a production workflow managed by merchant administrators in Activity.",
772
1131
  "delivery status": "Usage: tender-accounts delivery status --delivery dly_... [--json]\n\nRead one developer preview delivery. Do not pass a dwf_ production workflow ID.",
773
1132
  "delivery preview": "Usage: tender-accounts delivery preview --delivery dly_... [--return-path /account] [--json]\n\nMint a fresh 15-minute same-domain preview session. Do not pass a dwf_ production workflow ID.",
@@ -780,7 +1139,7 @@ function helpText(topic) {
780
1139
  };
781
1140
  if (topic && topics[topic])
782
1141
  return topics[topic];
783
- 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 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.`;
784
1143
  }
785
1144
  function normalizeError(error) {
786
1145
  if (error instanceof CliApiError) {