@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/README.md +29 -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 +4 -1
- package/dist/main.d.ts.map +1 -1
- package/dist/main.js +289 -66
- 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 -8
- package/dist/starter.js.map +1 -1
- package/package.json +3 -1
- package/skill/tender-accounts/SKILL.md +100 -10
- 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
|
@@ -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.
|
|
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 && !
|
|
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
|
-
?
|
|
243
|
-
:
|
|
244
|
-
?
|
|
245
|
-
:
|
|
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
|
|
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.
|
|
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:
|
|
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
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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",
|
|
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}.`,
|
|
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
|
|
543
|
-
throw new CliError("device_authorization_expired", "The pending device authorization expired.",
|
|
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.",
|
|
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
|
|
561
|
-
throw new CliError("authentication_expired", "The Tender Accounts login expired.",
|
|
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.",
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
|
600
|
-
throw new CliError("device_authorization_expired", "The device authorization expired.",
|
|
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(
|
|
878
|
+
? resolve(runtime.homeDirectory, ".agents/skills")
|
|
689
879
|
: agent === "claude"
|
|
690
|
-
? resolve(
|
|
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
|
-
|
|
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\
|
|
899
|
-
"auth
|
|
900
|
-
"auth
|
|
901
|
-
|
|
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
|
|
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) {
|