@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/README.md +26 -9
- package/dist/auth-store.d.ts +26 -7
- package/dist/auth-store.d.ts.map +1 -1
- package/dist/auth-store.js +165 -25
- package/dist/auth-store.js.map +1 -1
- package/dist/files.d.ts.map +1 -1
- package/dist/files.js +3 -0
- package/dist/files.js.map +1 -1
- package/dist/main.d.ts +2 -0
- package/dist/main.d.ts.map +1 -1
- package/dist/main.js +281 -59
- package/dist/main.js.map +1 -1
- package/dist/profile-bindings.d.ts +26 -0
- package/dist/profile-bindings.d.ts.map +1 -0
- package/dist/profile-bindings.js +214 -0
- package/dist/profile-bindings.js.map +1 -0
- package/dist/starter.d.ts.map +1 -1
- package/dist/starter.js +2 -1
- package/dist/starter.js.map +1 -1
- package/package.json +3 -1
- package/skill/tender-accounts/SKILL.md +99 -9
- package/templates/shopify-customer-account/AGENTS.md +1 -0
- package/templates/shopify-customer-account/README.md +5 -2
- package/templates/shopify-customer-account/apps/gateway/AGENTS.md +1 -0
- package/templates/shopify-customer-account/apps/gateway/dot.dev.vars.example +1 -1
- package/templates/shopify-customer-account/apps/gateway/src/portal-service.test.ts +59 -7
- package/templates/shopify-customer-account/apps/gateway/src/portal-service.ts +23 -3
- package/templates/shopify-customer-account/apps/portal/AGENTS.md +1 -0
- package/templates/shopify-customer-account/apps/portal/README.md +2 -0
- package/templates/shopify-customer-account/dot.dev.vars.example +1 -1
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 && !
|
|
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
|
-
?
|
|
243
|
-
:
|
|
244
|
-
?
|
|
245
|
-
:
|
|
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
|
|
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.
|
|
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:
|
|
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
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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",
|
|
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}.`,
|
|
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
|
|
543
|
-
throw new CliError("device_authorization_expired", "The pending device authorization expired.",
|
|
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.",
|
|
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
|
|
561
|
-
throw new CliError("authentication_expired", "The Tender Accounts login expired.",
|
|
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.",
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
|
600
|
-
throw new CliError("device_authorization_expired", "The device authorization expired.",
|
|
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
|
-
|
|
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\
|
|
899
|
-
"auth
|
|
900
|
-
"auth
|
|
901
|
-
|
|
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
|
|
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) {
|