@tenderprompt/accounts 0.6.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/main.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import { fileURLToPath } from "node:url";
2
2
  import { createRequire } from "node:module";
3
- import { copyFile, mkdir, readFile } from "node:fs/promises";
4
- import { basename, dirname, relative, resolve, sep } from "node:path";
5
- import { homedir, hostname } from "node:os";
3
+ import { copyFile, lstat, mkdir, mkdtemp, readFile, readdir, realpath, rm } from "node:fs/promises";
4
+ import { basename, dirname, join, relative, resolve, sep } from "node:path";
5
+ import { homedir, hostname, tmpdir } from "node:os";
6
6
  import { AgentApiClient } from "./api.js";
7
7
  import { CLI_AUTH_SCHEMA, DEFAULT_AUTH_PROFILE, createDefaultAuthStore, validateAuthProfile, validateNamedAuthProfile, } from "./auth-store.js";
8
8
  import { labelLocalArtifact, verifyArtifact } from "./artifact.js";
@@ -130,8 +130,24 @@ async function execute(topic, options, cwd, runtime) {
130
130
  return { value: await preview(cwd, options, runtime) };
131
131
  case "source status":
132
132
  return { value: await sourceStatus(cwd, options, runtime) };
133
+ case "source branches":
134
+ return { value: await sourceBranches(cwd, options, runtime) };
135
+ case "source log":
136
+ return { value: await sourceLog(cwd, options, runtime) };
137
+ case "source compare":
138
+ return { value: await sourceCompare(cwd, options, runtime) };
139
+ case "source default set":
140
+ return { value: await sourceDefaultSet(cwd, options, runtime) };
141
+ case "source promote":
142
+ return { value: await sourcePromote(cwd, options, runtime) };
143
+ case "source history replace":
144
+ return { value: await sourceHistoryReplace(cwd, options, runtime) };
145
+ case "source operation status":
146
+ return { value: await sourceOperationStatus(cwd, options, runtime) };
133
147
  case "source connect":
134
148
  return { value: await sourceConnect(cwd, options, runtime) };
149
+ case "source pull":
150
+ return { value: await sourcePull(cwd, options, runtime) };
135
151
  case "source token":
136
152
  return { value: await sourceToken(cwd, options, runtime) };
137
153
  case "source push":
@@ -150,11 +166,8 @@ async function execute(topic, options, cwd, runtime) {
150
166
  }
151
167
  }
152
168
  async function sourceStatus(cwd, options, runtime) {
153
- const context = await loadProjectContext(cwd);
154
- if (!context.link)
155
- throw new CliError("project_not_linked", "This app is not linked to a Tender Accounts project.", "tender-accounts link --json");
156
- const projectId = requiredProjectId(context.link.projectId);
157
- const { client } = await authenticatedClient(cwd, context.link, options, runtime);
169
+ const context = await optionalContext(cwd);
170
+ const { client, projectId } = await sourceProjectAccess(cwd, context?.link ?? null, options, runtime, false);
158
171
  const result = await client.sourceRepository(projectId);
159
172
  return {
160
173
  status: result.repository ? "connected" : "not_connected",
@@ -162,6 +175,517 @@ async function sourceStatus(cwd, options, runtime) {
162
175
  repository: result.repository,
163
176
  };
164
177
  }
178
+ async function sourceBranches(cwd, options, runtime) {
179
+ const access = await sourceReadAccess(cwd, options, runtime);
180
+ let credential = null;
181
+ try {
182
+ const token = await access.client.sourceToken(access.repository.id, 15 * 60, "read");
183
+ credential = token.token;
184
+ const result = await runtime.captureCommand("git", ["ls-remote", "--heads", access.repository.remoteUrl], cwd, managedGitEnvironment(runtime.environment, credential, managedGitCredentialHeaderKey(access.repository.remoteUrl)));
185
+ const branches = parseManagedSourceRefs(result.stdout).map(({ ref, sha }) => ({
186
+ name: ref.slice("refs/heads/".length),
187
+ sha,
188
+ default: ref === `refs/heads/${access.repository.defaultBranch}`,
189
+ providerDefault: access.repository.providerDefaultBranch !== null
190
+ && ref === `refs/heads/${access.repository.providerDefaultBranch}`,
191
+ }));
192
+ return {
193
+ status: "ready",
194
+ projectId: access.projectId,
195
+ repositoryId: access.repository.id,
196
+ defaultBranch: access.repository.defaultBranch,
197
+ providerDefaultBranch: access.repository.providerDefaultBranch,
198
+ branches,
199
+ credentialPersisted: false,
200
+ };
201
+ }
202
+ finally {
203
+ credential = null;
204
+ }
205
+ }
206
+ async function sourceLog(cwd, options, runtime) {
207
+ const access = await sourceReadAccess(cwd, options, runtime);
208
+ const branch = options.flags.get("branch") ?? access.repository.defaultBranch;
209
+ assertGitBranchName(branch);
210
+ const limit = numberFlag(options, "limit", 1, 100, 20);
211
+ return withManagedSourceMirror(cwd, access.repository, access.client, runtime, async (mirror) => {
212
+ const refs = await managedSourceMirrorRefs(mirror, runtime);
213
+ if (!refs.has(`refs/heads/${branch}`)) {
214
+ throw new CliError("source_branch_missing", `Managed source branch ${branch} does not exist.`);
215
+ }
216
+ await runtime.captureCommand("git", ["fetch", "--no-tags", "origin", `refs/heads/${branch}:refs/remotes/origin/${branch}`], mirror.path, mirror.environment);
217
+ const result = await runtime.captureCommand("git", ["log", `--max-count=${limit}`, "--format=%H%x09%P%x09%aI%x09%an%x09%s", `refs/remotes/origin/${branch}`], mirror.path, managedGitCheckoutEnvironment(runtime.environment));
218
+ return {
219
+ status: "ready",
220
+ projectId: access.projectId,
221
+ repositoryId: access.repository.id,
222
+ branch,
223
+ commits: result.stdout.split(/\r?\n/u).filter(Boolean).map(parseManagedSourceCommit),
224
+ credentialPersisted: false,
225
+ };
226
+ });
227
+ }
228
+ async function sourceCompare(cwd, options, runtime) {
229
+ const access = await sourceReadAccess(cwd, options, runtime);
230
+ const base = requiredFlag(options, "base");
231
+ const head = requiredFlag(options, "head");
232
+ assertGitBranchName(base);
233
+ assertGitBranchName(head);
234
+ return withManagedSourceMirror(cwd, access.repository, access.client, runtime, async (mirror) => {
235
+ const refs = await managedSourceMirrorRefs(mirror, runtime);
236
+ const missing = [base, head].filter((branch) => !refs.has(`refs/heads/${branch}`));
237
+ if (missing.length > 0) {
238
+ throw new CliError("source_branch_missing", `Managed source branch ${missing.join(", ")} does not exist.`);
239
+ }
240
+ await runtime.captureCommand("git", [
241
+ "fetch", "--no-tags", "origin",
242
+ `refs/heads/${base}:refs/remotes/origin/${base}`,
243
+ `refs/heads/${head}:refs/remotes/origin/${head}`,
244
+ ], mirror.path, mirror.environment);
245
+ const checkoutEnv = managedGitCheckoutEnvironment(runtime.environment);
246
+ const baseSha = (await runtime.captureCommand("git", ["rev-parse", `refs/remotes/origin/${base}`], mirror.path, checkoutEnv)).stdout;
247
+ const headSha = (await runtime.captureCommand("git", ["rev-parse", `refs/remotes/origin/${head}`], mirror.path, checkoutEnv)).stdout;
248
+ let mergeBase = null;
249
+ try {
250
+ mergeBase = (await runtime.captureCommand("git", ["merge-base", baseSha, headSha], mirror.path, checkoutEnv)).stdout || null;
251
+ }
252
+ catch {
253
+ mergeBase = null;
254
+ }
255
+ const counts = (await runtime.captureCommand("git", ["rev-list", "--left-right", "--count", `${baseSha}...${headSha}`], mirror.path, checkoutEnv)).stdout.split(/\s+/u).map(Number);
256
+ const changedFiles = (await runtime.captureCommand("git", ["diff", "--name-status", "--no-renames", baseSha, headSha], mirror.path, checkoutEnv)).stdout.split(/\r?\n/u).filter(Boolean);
257
+ const files = changedFiles.slice(0, 500).map((line) => {
258
+ const [status, ...path] = line.split("\t");
259
+ return { status, path: path.join("\t") };
260
+ });
261
+ const relationship = baseSha === headSha
262
+ ? "identical"
263
+ : mergeBase === baseSha
264
+ ? "fast_forward"
265
+ : mergeBase === headSha
266
+ ? "behind"
267
+ : mergeBase === null
268
+ ? "unrelated"
269
+ : "diverged";
270
+ return {
271
+ status: "ready",
272
+ projectId: access.projectId,
273
+ repositoryId: access.repository.id,
274
+ base: { branch: base, sha: baseSha, commitsOnlyHere: counts[0] ?? 0 },
275
+ head: { branch: head, sha: headSha, commitsOnlyHere: counts[1] ?? 0 },
276
+ mergeBase,
277
+ relationship,
278
+ files,
279
+ filesTruncated: changedFiles.length > files.length,
280
+ credentialPersisted: false,
281
+ };
282
+ });
283
+ }
284
+ async function sourceDefaultSet(cwd, options, runtime) {
285
+ const targetBranch = requiredFlag(options, "branch");
286
+ const expectedDefaultBranch = requiredFlag(options, "expected-current");
287
+ assertGitBranchName(targetBranch);
288
+ assertGitBranchName(expectedDefaultBranch);
289
+ return requestAndWaitForSourceOperation(cwd, options, runtime, {
290
+ kind: "set_default",
291
+ targetBranch,
292
+ expectedDefaultBranch,
293
+ });
294
+ }
295
+ async function sourcePromote(cwd, options, runtime) {
296
+ const headBranch = requiredFlag(options, "head");
297
+ const expectedCurrentSha = requiredSourceSha(options, "expected-current");
298
+ assertGitBranchName(headBranch);
299
+ const access = await sourceReadAccess(cwd, options, runtime);
300
+ const targetBranch = options.flags.get("base") ?? access.repository.defaultBranch;
301
+ assertGitBranchName(targetBranch);
302
+ return requestAndWaitForSourceOperation(cwd, options, runtime, {
303
+ kind: "promote",
304
+ targetBranch,
305
+ headBranch,
306
+ expectedCurrentSha,
307
+ }, access);
308
+ }
309
+ async function sourceHistoryReplace(cwd, options, runtime) {
310
+ const access = await sourceReadAccess(cwd, options, runtime);
311
+ const confirmation = requiredFlag(options, "confirm");
312
+ if (confirmation !== access.repository.id) {
313
+ throw new CliError("source_history_confirmation_required", "History replacement requires --confirm with the exact managed repository ID.", `tender-accounts source history replace --head ${requiredFlag(options, "head")} --expected-current <sha> --confirm ${access.repository.id} --dry-run --json`);
314
+ }
315
+ const headBranch = requiredFlag(options, "head");
316
+ const targetBranch = options.flags.get("target") ?? access.repository.defaultBranch;
317
+ const expectedCurrentSha = requiredSourceSha(options, "expected-current");
318
+ assertGitBranchName(headBranch);
319
+ assertGitBranchName(targetBranch);
320
+ return requestAndWaitForSourceOperation(cwd, options, runtime, {
321
+ kind: "history_replace",
322
+ targetBranch,
323
+ headBranch,
324
+ expectedCurrentSha,
325
+ }, access);
326
+ }
327
+ async function sourceOperationStatus(cwd, options, runtime) {
328
+ const context = await optionalContext(cwd);
329
+ const { client } = await authenticatedClient(cwd, context?.link ?? null, options, runtime);
330
+ return (await client.sourceOperation(requiredSourceOperationId(requiredFlag(options, "operation")))).operation;
331
+ }
332
+ async function requestAndWaitForSourceOperation(cwd, options, runtime, input, resolvedAccess) {
333
+ const access = resolvedAccess ?? await sourceReadAccess(cwd, options, runtime);
334
+ const idempotencyKey = options.flags.get("idempotency-key")
335
+ ?? `cli-${input.kind}-${crypto.randomUUID()}`;
336
+ const requested = await access.client.createSourceOperation(access.repository.id, {
337
+ ...input,
338
+ idempotencyKey,
339
+ dryRun: options.booleans.has("dry-run"),
340
+ });
341
+ if (options.booleans.has("no-wait"))
342
+ return requested.operation;
343
+ const timeoutSeconds = numberFlag(options, "timeout-seconds", 10, 15 * 60, 10 * 60);
344
+ const deadline = Date.now() + timeoutSeconds * 1000;
345
+ let operation = requested.operation;
346
+ while (operation.status === "requested" || operation.status === "running") {
347
+ if (Date.now() >= deadline) {
348
+ throw new CliError("source_operation_timeout", `Source operation ${operation.id} is still ${operation.status}.`, `tender-accounts source operation status --operation ${operation.id} --json`);
349
+ }
350
+ await runtime.sleep(2_000);
351
+ operation = (await access.client.sourceOperation(operation.id)).operation;
352
+ }
353
+ if (operation.status === "failed") {
354
+ throw new CliError(operation.error?.code ?? "source_operation_failed", operation.error?.message ?? "The managed source operation failed.", `tender-accounts source operation status --operation ${operation.id} --json`);
355
+ }
356
+ return operation;
357
+ }
358
+ async function sourceReadAccess(cwd, options, runtime) {
359
+ const context = await optionalContext(cwd);
360
+ const access = await sourceProjectAccess(cwd, context?.link ?? null, options, runtime, false);
361
+ const source = await access.client.sourceRepository(access.projectId);
362
+ if (!source.repository) {
363
+ throw new CliError("source_repository_missing", "This project does not have a Tender-managed Git repository.");
364
+ }
365
+ if (source.repository.status !== "active") {
366
+ throw new CliError("source_repository_paused", "This Tender-managed repository is paused.");
367
+ }
368
+ return { ...access, repository: source.repository };
369
+ }
370
+ async function withManagedSourceMirror(cwd, repository, client, runtime, callback) {
371
+ const mirrorPath = await mkdtemp(join(tmpdir(), "tender-accounts-source-read-"));
372
+ let credential = null;
373
+ try {
374
+ await runtime.captureCommand("git", ["init", "--bare", mirrorPath], cwd, managedGitCheckoutEnvironment(runtime.environment));
375
+ await runtime.captureCommand("git", ["remote", "add", "origin", repository.remoteUrl], mirrorPath, managedGitCheckoutEnvironment(runtime.environment));
376
+ const token = await client.sourceToken(repository.id, 15 * 60, "read");
377
+ credential = token.token;
378
+ return await callback({
379
+ path: mirrorPath,
380
+ environment: managedGitEnvironment(runtime.environment, credential, managedGitCredentialHeaderKey(repository.remoteUrl)),
381
+ });
382
+ }
383
+ finally {
384
+ credential = null;
385
+ await rm(mirrorPath, { recursive: true, force: true });
386
+ }
387
+ }
388
+ function parseManagedSourceRefs(stdout) {
389
+ const refs = [];
390
+ for (const line of stdout.split(/\r?\n/u)) {
391
+ const match = /^([0-9a-f]{40})\s+(refs\/heads\/[A-Za-z0-9._/-]{1,480})$/u.exec(line);
392
+ if (match?.[1] && match[2])
393
+ refs.push({ sha: match[1], ref: match[2] });
394
+ }
395
+ return refs.sort((left, right) => left.ref.localeCompare(right.ref));
396
+ }
397
+ async function managedSourceMirrorRefs(mirror, runtime) {
398
+ const result = await runtime.captureCommand("git", ["ls-remote", "--heads", "origin"], mirror.path, mirror.environment);
399
+ return new Map(parseManagedSourceRefs(result.stdout).map(({ ref, sha }) => [ref, sha]));
400
+ }
401
+ function parseManagedSourceCommit(line) {
402
+ const [sha, parents, authoredAt, author, ...subject] = line.split("\t");
403
+ return {
404
+ sha,
405
+ parents: parents ? parents.split(" ").filter(Boolean) : [],
406
+ authoredAt,
407
+ author,
408
+ subject: subject.join("\t"),
409
+ };
410
+ }
411
+ function requiredSourceSha(options, name) {
412
+ const value = requiredFlag(options, name);
413
+ if (!/^[0-9a-f]{40}$/u.test(value)) {
414
+ throw new CliError("source_revision_invalid", `--${name} must be an exact lowercase 40-character Git SHA.`);
415
+ }
416
+ return value;
417
+ }
418
+ function requiredSourceOperationId(value) {
419
+ if (!/^sop_[A-Za-z0-9_-]{8,64}$/u.test(value)) {
420
+ throw new CliError("source_operation_id_invalid", "--operation must be the sop_ ID returned by a source mutation.");
421
+ }
422
+ return value;
423
+ }
424
+ async function sourcePull(cwd, options, runtime) {
425
+ const context = await optionalContext(cwd);
426
+ const access = await sourceProjectAccess(cwd, context?.link ?? null, options, runtime, true);
427
+ const source = await access.client.sourceRepository(access.projectId);
428
+ if (!source.repository) {
429
+ throw new CliError("source_repository_missing", "This project does not have an active Tender-managed Git repository to pull.", "Open the merchant-hosted connected repository, or ask a merchant administrator to connect Tender-managed Git.");
430
+ }
431
+ const repository = source.repository;
432
+ if (repository.status !== "active") {
433
+ throw new CliError("source_repository_paused", "This Tender-managed repository is paused and cannot be pulled.", "Use tender-accounts source status --project <project-id> --json to inspect the authoritative source.");
434
+ }
435
+ const credentialHeaderKey = managedGitCredentialHeaderKey(repository.remoteUrl);
436
+ const remote = options.flags.get("remote") ?? "tender";
437
+ assertGitRemoteName(remote);
438
+ const existingGitRoot = await optionalGitRoot(cwd, runtime);
439
+ let checkoutRoot;
440
+ let action;
441
+ let selectedRemote = remote;
442
+ let selectedBranch = repository.defaultBranch;
443
+ let beforeRevision = null;
444
+ if (!existingGitRoot) {
445
+ const entries = await readdir(cwd);
446
+ if (entries.length > 0) {
447
+ throw new CliError("source_pull_target_not_empty", "Tender can clone the connected repository only into an empty directory.", "Run this command from an empty workspace, or open the existing matching Git checkout and retry there.");
448
+ }
449
+ checkoutRoot = cwd;
450
+ action = "clone";
451
+ }
452
+ else {
453
+ checkoutRoot = existingGitRoot;
454
+ action = "pull";
455
+ selectedRemote = await matchingSourceRemote(checkoutRoot, remote, repository.remoteUrl, runtime);
456
+ selectedBranch = (await runtime.captureCommand("git", ["branch", "--show-current"], checkoutRoot)).stdout;
457
+ if (!selectedBranch) {
458
+ throw new CliError("source_pull_detached_head", "Tender will not pull while the checkout has a detached HEAD.", `Switch to ${repository.defaultBranch}, then retry tender-accounts source pull.`);
459
+ }
460
+ const state = await sourceState(checkoutRoot, runtime);
461
+ if (state.dirty) {
462
+ throw new CliError("source_pull_dirty_worktree", "Tender will not pull over uncommitted or untracked files.", "Commit, stash, or remove the local changes, then retry tender-accounts source pull.");
463
+ }
464
+ beforeRevision = state.revision;
465
+ }
466
+ assertGitBranchName(selectedBranch);
467
+ let appRoot = managedSourceAppRoot(checkoutRoot, repository.workingDirectory);
468
+ if (action === "pull") {
469
+ appRoot = await verifiedManagedSourceAppRoot(checkoutRoot, repository.workingDirectory);
470
+ await compatibleManagedSourceContext(appRoot, access.projectId, access.apiUrl);
471
+ }
472
+ if (options.booleans.has("dry-run")) {
473
+ return {
474
+ status: "planned",
475
+ action,
476
+ projectId: access.projectId,
477
+ repositoryId: repository.id,
478
+ remote: selectedRemote,
479
+ remoteUrl: repository.remoteUrl,
480
+ branch: selectedBranch,
481
+ checkoutRoot,
482
+ appRoot,
483
+ credentialIssued: false,
484
+ wroteFiles: false,
485
+ };
486
+ }
487
+ let credential = null;
488
+ try {
489
+ const token = await access.client.sourceToken(repository.id, 15 * 60);
490
+ if (token.remoteUrl !== repository.remoteUrl) {
491
+ throw new CliError("source_token_target_mismatch", "Tender refused a repository credential whose target did not match the connected source.");
492
+ }
493
+ credential = token.token;
494
+ const environment = managedGitEnvironment(runtime.environment, credential, credentialHeaderKey);
495
+ if (action === "clone") {
496
+ await runtime.captureCommand("git", [
497
+ "clone",
498
+ "--no-checkout",
499
+ "--no-recurse-submodules",
500
+ "--origin", selectedRemote,
501
+ "--branch", repository.defaultBranch,
502
+ repository.remoteUrl,
503
+ ".",
504
+ ], checkoutRoot, environment);
505
+ }
506
+ else {
507
+ await runtime.captureCommand("git", ["fetch", "--no-recurse-submodules", selectedRemote, selectedBranch], checkoutRoot, environment);
508
+ }
509
+ }
510
+ finally {
511
+ credential = null;
512
+ }
513
+ const checkoutEnvironment = managedGitCheckoutEnvironment(runtime.environment);
514
+ if (action === "clone") {
515
+ await runtime.captureCommand("git", ["reset", "--hard", "HEAD"], checkoutRoot, checkoutEnvironment);
516
+ }
517
+ else {
518
+ await runtime.captureCommand("git", ["merge", "--ff-only", "FETCH_HEAD"], checkoutRoot, checkoutEnvironment);
519
+ }
520
+ appRoot = await verifiedManagedSourceAppRoot(checkoutRoot, repository.workingDirectory);
521
+ const pulledContext = await compatibleManagedSourceContext(appRoot, access.projectId, access.apiUrl);
522
+ const linkCreated = !pulledContext.link;
523
+ const linkPath = linkCreated
524
+ ? await writeProjectLink(appRoot, {
525
+ schema: CLI_LINK_SCHEMA,
526
+ projectId: access.projectId,
527
+ apiUrl: access.apiUrl,
528
+ })
529
+ : pulledContext.linkPath;
530
+ const afterRevision = (await sourceState(checkoutRoot, runtime)).revision;
531
+ return {
532
+ status: action === "clone" ? "cloned" : beforeRevision === afterRevision ? "up_to_date" : "updated",
533
+ action,
534
+ projectId: access.projectId,
535
+ repositoryId: repository.id,
536
+ remote: selectedRemote,
537
+ branch: selectedBranch,
538
+ checkoutRoot,
539
+ appRoot,
540
+ sourceRevision: afterRevision,
541
+ linkPath,
542
+ linkCreated,
543
+ credentialPersisted: false,
544
+ next: appRoot === checkoutRoot
545
+ ? "tender-accounts doctor --json"
546
+ : `tender-accounts doctor --cwd ${repository.workingDirectory} --json`,
547
+ };
548
+ }
549
+ async function compatibleManagedSourceContext(appRoot, projectId, apiUrl) {
550
+ const context = await loadProjectContext(appRoot).catch((error) => {
551
+ if (error instanceof CliError && error.code === "file_missing") {
552
+ throw new CliError("source_checkout_config_missing", `The connected repository does not contain tender-accounts.json at ${appRoot}.`, "Ask a merchant administrator to repair the managed source working directory before continuing.");
553
+ }
554
+ throw error;
555
+ });
556
+ if (context.link && (context.link.projectId !== projectId
557
+ || context.link.apiUrl !== apiUrl)) {
558
+ throw new CliError("source_checkout_link_conflict", "The pulled app is already linked to a different Tender project or API origin.", "Do not replace the link automatically; verify the checkout and project with a merchant administrator.");
559
+ }
560
+ return context;
561
+ }
562
+ async function sourceProjectAccess(cwd, link, options, runtime, requireDeveloper) {
563
+ const resolved = await authenticatedClient(cwd, link, options, runtime);
564
+ const principal = await resolved.client.whoami();
565
+ if (resolved.profile)
566
+ await savePrincipalSummary(runtime.authStore, resolved.profile, principal);
567
+ const explicitProject = options.flags.get("project");
568
+ const requestedProject = explicitProject ? requiredProjectId(explicitProject) : link?.projectId;
569
+ const project = requestedProject
570
+ ? principal.projects.find((candidate) => candidate.projectId === requestedProject)
571
+ : principal.projects.length === 1 ? principal.projects[0] : undefined;
572
+ if (!project) {
573
+ throw new CliError(requestedProject ? "project_not_permitted" : "project_required", requestedProject
574
+ ? "The current credential is not permitted to use that project."
575
+ : "The credential grants more than one project; select one explicitly.", `tender-accounts source status --project ${principal.projects[0]?.projectId ?? "prj_..."} --json`);
576
+ }
577
+ if (explicitProject && link && project.projectId !== link.projectId) {
578
+ throw new CliError("project_link_mismatch", "The requested project does not match this checkout's Tender link.", "Run the command from an empty workspace, or use the project already recorded in .tender/link.json.");
579
+ }
580
+ if (requireDeveloper && project.role !== "developer") {
581
+ throw new CliError("project_role_insufficient", "The selected project grant is read-only and cannot issue a repository credential.", "Ask a merchant administrator for developer access to this exact project.");
582
+ }
583
+ return { client: resolved.client, apiUrl: resolved.apiUrl, projectId: project.projectId };
584
+ }
585
+ async function optionalGitRoot(cwd, runtime) {
586
+ try {
587
+ return resolve((await runtime.captureCommand("git", ["rev-parse", "--show-toplevel"], cwd)).stdout);
588
+ }
589
+ catch {
590
+ return null;
591
+ }
592
+ }
593
+ async function matchingSourceRemote(gitRoot, requestedRemote, remoteUrl, runtime) {
594
+ const remotes = (await runtime.captureCommand("git", ["remote"], gitRoot)).stdout.split(/\r?\n/u).filter(Boolean);
595
+ const matches = [];
596
+ for (const remote of remotes) {
597
+ const current = (await runtime.captureCommand("git", ["remote", "get-url", remote], gitRoot)).stdout;
598
+ if (current === remoteUrl)
599
+ matches.push(remote);
600
+ if (remote === requestedRemote && current !== remoteUrl) {
601
+ throw new CliError("source_remote_conflict", `Git remote ${requestedRemote} points somewhere other than this project's connected source.`, "Open the matching checkout or choose its existing remote with --remote after verifying the URL.");
602
+ }
603
+ }
604
+ if (matches.includes(requestedRemote))
605
+ return requestedRemote;
606
+ if (matches.length === 1)
607
+ return matches[0];
608
+ if (matches.length > 1) {
609
+ throw new CliError("source_remote_ambiguous", "More than one Git remote points to the connected Tender source.", "Select the intended existing remote explicitly with --remote.");
610
+ }
611
+ throw new CliError("source_checkout_mismatch", "This Git checkout does not match the project's connected Tender source.", "Run tender-accounts source pull from an empty workspace to clone the exact repository.");
612
+ }
613
+ function managedSourceAppRoot(checkoutRoot, workingDirectory) {
614
+ if (workingDirectory === ".")
615
+ return checkoutRoot;
616
+ if (!workingDirectory || workingDirectory.startsWith("/") || workingDirectory.includes("\\") || workingDirectory.includes("%")) {
617
+ throw new CliError("source_working_directory_invalid", "The managed source working directory is unsafe.");
618
+ }
619
+ const segments = workingDirectory.split("/");
620
+ if (segments.some((segment) => !segment || segment === "." || segment === ".." || !/^[A-Za-z0-9._-]+$/u.test(segment))) {
621
+ throw new CliError("source_working_directory_invalid", "The managed source working directory is unsafe.");
622
+ }
623
+ const appRoot = resolve(checkoutRoot, workingDirectory);
624
+ const pathFromCheckout = relative(checkoutRoot, appRoot);
625
+ if (pathFromCheckout === ".." || pathFromCheckout.startsWith(`..${sep}`)) {
626
+ throw new CliError("source_working_directory_invalid", "The managed source working directory escapes the checkout.");
627
+ }
628
+ return appRoot;
629
+ }
630
+ async function verifiedManagedSourceAppRoot(checkoutRoot, workingDirectory) {
631
+ const lexicalAppRoot = managedSourceAppRoot(checkoutRoot, workingDirectory);
632
+ const realCheckoutRoot = await realpath(checkoutRoot);
633
+ let cursor = realCheckoutRoot;
634
+ if (workingDirectory !== ".") {
635
+ for (const segment of workingDirectory.split("/")) {
636
+ cursor = resolve(cursor, segment);
637
+ let entry;
638
+ try {
639
+ entry = await lstat(cursor);
640
+ }
641
+ catch (error) {
642
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") {
643
+ throw new CliError("source_working_directory_missing", `The managed source working directory does not exist: ${workingDirectory}.`, "Ask a merchant administrator to repair the connected source working directory.");
644
+ }
645
+ throw error;
646
+ }
647
+ if (entry.isSymbolicLink() || !entry.isDirectory()) {
648
+ throw new CliError("source_working_directory_unsafe", `The managed source working directory contains a symlink or non-directory component: ${workingDirectory}.`, "Replace it with regular directories inside the connected repository before retrying.");
649
+ }
650
+ }
651
+ }
652
+ const realAppRoot = await realpath(lexicalAppRoot);
653
+ const pathFromCheckout = relative(realCheckoutRoot, realAppRoot);
654
+ if (pathFromCheckout === ".." || pathFromCheckout.startsWith(`..${sep}`)) {
655
+ throw new CliError("source_working_directory_unsafe", "The managed source working directory escapes the checkout.");
656
+ }
657
+ return lexicalAppRoot;
658
+ }
659
+ function managedGitCredentialHeaderKey(remoteUrl) {
660
+ let url;
661
+ try {
662
+ url = new URL(remoteUrl);
663
+ }
664
+ catch {
665
+ throw new CliError("source_remote_invalid", "The connected managed Git URL is invalid.");
666
+ }
667
+ if (url.protocol !== "https:" || url.username || url.password || url.search || url.hash) {
668
+ throw new CliError("source_remote_invalid", "The connected managed Git URL must be credential-free HTTPS without query parameters or fragments.");
669
+ }
670
+ return `http.${url.toString()}.extraHeader`;
671
+ }
672
+ function managedGitEnvironment(environment, credential, credentialHeaderKey) {
673
+ return {
674
+ ...environment,
675
+ GIT_CONFIG_COUNT: "1",
676
+ GIT_CONFIG_KEY_0: credentialHeaderKey,
677
+ GIT_CONFIG_VALUE_0: `Authorization: Bearer ${credential}`,
678
+ GIT_TERMINAL_PROMPT: "0",
679
+ GIT_LFS_SKIP_SMUDGE: "1",
680
+ };
681
+ }
682
+ function managedGitCheckoutEnvironment(environment) {
683
+ return {
684
+ ...environment,
685
+ GIT_TERMINAL_PROMPT: "0",
686
+ GIT_LFS_SKIP_SMUDGE: "1",
687
+ };
688
+ }
165
689
  async function sourceConnect(cwd, options, runtime) {
166
690
  const context = await loadProjectContext(cwd);
167
691
  if (!context.link)
@@ -229,9 +753,7 @@ async function sourcePush(cwd, options, runtime) {
229
753
  await ensureGitRemote(gitRoot, remote, source.repository.remoteUrl, runtime);
230
754
  const currentBranch = (await runtime.captureCommand("git", ["branch", "--show-current"], gitRoot)).stdout;
231
755
  const branch = options.flags.get("branch") ?? (currentBranch || source.repository.defaultBranch);
232
- if (!/^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$/u.test(branch)) {
233
- throw new CliError("source_branch_invalid", "The managed source branch name is invalid.");
234
- }
756
+ assertGitBranchName(branch);
235
757
  const state = await sourceState(gitRoot, runtime);
236
758
  let credential = null;
237
759
  try {
@@ -268,9 +790,7 @@ async function requireGitRoot(root, runtime) {
268
790
  }
269
791
  }
270
792
  async function ensureGitRemote(gitRoot, remote, remoteUrl, runtime) {
271
- if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/u.test(remote)) {
272
- throw new CliError("source_remote_invalid", "The Git remote name is invalid.");
273
- }
793
+ assertGitRemoteName(remote);
274
794
  const remotes = (await runtime.captureCommand("git", ["remote"], gitRoot)).stdout.split(/\r?\n/u).filter(Boolean);
275
795
  if (!remotes.includes(remote)) {
276
796
  await runtime.captureCommand("git", ["remote", "add", remote, remoteUrl], gitRoot);
@@ -281,6 +801,16 @@ async function ensureGitRemote(gitRoot, remote, remoteUrl, runtime) {
281
801
  throw new CliError("source_remote_conflict", `Git remote ${remote} already points somewhere else. Tender will not replace it automatically.`, `Choose another name with --remote, or update ${remote} yourself after verifying the URL.`);
282
802
  }
283
803
  }
804
+ function assertGitRemoteName(remote) {
805
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/u.test(remote)) {
806
+ throw new CliError("source_remote_invalid", "The Git remote name is invalid.");
807
+ }
808
+ }
809
+ function assertGitBranchName(branch) {
810
+ if (!/^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$/u.test(branch)) {
811
+ throw new CliError("source_branch_invalid", "The managed source branch name is invalid.");
812
+ }
813
+ }
284
814
  function safeRepositoryName(value) {
285
815
  const normalized = value.replaceAll(/[^A-Za-z0-9._-]+/gu, "-").replace(/^-+|-+$/gu, "");
286
816
  return normalized.length >= 2 ? normalized.slice(0, 100) : "tender-app";
@@ -1076,6 +1606,15 @@ function commandTopic(argv) {
1076
1606
  const child = argv[1];
1077
1607
  if (!child || child.startsWith("--"))
1078
1608
  return { topic: "help", values: [parent] };
1609
+ if (parent === "source" && (child === "default" || child === "history" || child === "operation")) {
1610
+ const grandchild = argv[2];
1611
+ if (!grandchild || grandchild.startsWith("--"))
1612
+ return { topic: "help", values: [`${parent} ${child}`] };
1613
+ if (argv.includes("--help") || argv.includes("-h")) {
1614
+ return { topic: "help", values: [`${parent} ${child} ${grandchild}`] };
1615
+ }
1616
+ return { topic: `${parent} ${child} ${grandchild}`, values: argv.slice(3) };
1617
+ }
1079
1618
  if (argv.includes("--help") || argv.includes("-h"))
1080
1619
  return { topic: "help", values: [`${parent} ${child}`] };
1081
1620
  return { topic: `${parent} ${child}`, values: argv.slice(2) };
@@ -1154,7 +1693,23 @@ function assertAllowedOptions(topic, options) {
1154
1693
  "timeout-seconds",
1155
1694
  "return-path",
1156
1695
  ],
1157
- "source status": [...common, ...authentication],
1696
+ "source status": [...common, ...authentication, "project"],
1697
+ "source branches": [...common, ...authentication, "project"],
1698
+ "source log": [...common, ...authentication, "project", "branch", "limit"],
1699
+ "source compare": [...common, ...authentication, "project", "base", "head"],
1700
+ "source default set": [
1701
+ ...common, ...authentication, "project", "branch", "expected-current",
1702
+ "idempotency-key", "dry-run", "no-wait", "timeout-seconds",
1703
+ ],
1704
+ "source promote": [
1705
+ ...common, ...authentication, "project", "head", "base", "expected-current",
1706
+ "idempotency-key", "dry-run", "no-wait", "timeout-seconds",
1707
+ ],
1708
+ "source history replace": [
1709
+ ...common, ...authentication, "project", "head", "target", "expected-current", "confirm",
1710
+ "idempotency-key", "dry-run", "no-wait", "timeout-seconds",
1711
+ ],
1712
+ "source operation status": [...common, ...authentication, "operation"],
1158
1713
  "source connect": [
1159
1714
  ...common,
1160
1715
  ...authentication,
@@ -1166,6 +1721,7 @@ function assertAllowedOptions(topic, options) {
1166
1721
  "working-directory",
1167
1722
  "artifact-path",
1168
1723
  ],
1724
+ "source pull": [...common, ...authentication, "project", "remote", "dry-run"],
1169
1725
  "source token": [...common, ...authentication, "ttl-seconds"],
1170
1726
  "source push": [...common, ...authentication, "branch", "remote"],
1171
1727
  tail: [
@@ -1298,9 +1854,20 @@ function helpText(topic) {
1298
1854
  check: "Usage: tender-accounts check [--json]\n\nRun the app's declared validation command.",
1299
1855
  build: "Usage: tender-accounts build [--json]\n\nRun validation and packaging, then verify the complete portable artifact.",
1300
1856
  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.",
1301
- source: "Usage: tender-accounts source <status|connect|token|push> [options]\n\nUse Tender-managed Git for the linked gateway or service project.",
1302
- "source status": "Usage: tender-accounts source status [--json]\n\nRead the managed repository, immutable build contract, and recent source builds for this linked project.",
1857
+ source: "Usage: tender-accounts source <status|branches|log|compare|connect|pull|push|default|promote|history|operation> [options]\n\nUse standard Git through Tender for a gateway or service project. Source changes never publish production directly.",
1858
+ "source status": "Usage: tender-accounts source status [--project PROJECT_ID] [--json]\n\nRead the managed repository, immutable build contract, and recent source builds. --project works before a checkout is linked.\n\nExamples:\n tender-accounts source status --project prj_... --json\n tender-accounts source status --json",
1859
+ "source branches": "Usage: tender-accounts source branches [--project PROJECT_ID] [--json]\n\nList exact branch refs and identify Tender's authoritative default separately from the provider's create-time default. Uses a short-lived read credential without persisting it.",
1860
+ "source log": "Usage: tender-accounts source log [--project PROJECT_ID] [--branch BRANCH] [--limit 20] [--json]\n\nRead bounded commit history from one exact managed branch without requiring a checkout.",
1861
+ "source compare": "Usage: tender-accounts source compare [--project PROJECT_ID] --base BRANCH --head BRANCH [--json]\n\nCompare exact managed refs and report identical, fast-forward, behind, diverged, or unrelated history plus a bounded file list.",
1862
+ "source default": "Usage: tender-accounts source default set --branch BRANCH --expected-current BRANCH [--dry-run] [--json]",
1863
+ "source default set": "Usage: tender-accounts source default set [--project PROJECT_ID] --branch BRANCH --expected-current BRANCH [--idempotency-key KEY] [--dry-run] [--no-wait] [--json]\n\nSet an existing branch as Tender's authoritative default with compare-and-swap. This metadata-only operation cannot change production.",
1864
+ "source promote": "Usage: tender-accounts source promote [--project PROJECT_ID] --head BRANCH [--base BRANCH] --expected-current SHA [--idempotency-key KEY] [--dry-run] [--no-wait] [--json]\n\nPromote a successfully previewed head into the current default branch using an exact expected-SHA lease. Normal preview/publication policy applies to the resulting push; protected gateways remain preview-only.",
1865
+ "source history": "Usage: tender-accounts source history replace --head BRANCH --expected-current SHA --confirm REPOSITORY_ID [--dry-run] [--json]",
1866
+ "source history replace": "Usage: tender-accounts source history replace [--project PROJECT_ID] --head BRANCH [--target BRANCH] --expected-current SHA --confirm REPOSITORY_ID [--idempotency-key KEY] [--dry-run] [--no-wait] [--json]\n\nExceptional source migration only: archive the exact previous target, then replace it with an expected-SHA compare-and-swap. Automatic production publication is always suppressed.",
1867
+ "source operation": "Usage: tender-accounts source operation status --operation sop_... [--json]",
1868
+ "source operation status": "Usage: tender-accounts source operation status --operation sop_... [--json]\n\nRead durable status and safe evidence for one exact source operation.",
1303
1869
  "source connect": "Usage: tender-accounts source connect [--repo-name NAME] [--default-branch NAME] [--publication preview-only|default-branch] [--remote NAME] [--install-command COMMAND] [--working-directory PATH] [--artifact-path PATH] [--json]\n\nCreate or reconnect this linked project to Tender-managed Git. Build and artifact commands come from tender-accounts.json. Protected gateways accept preview-only publication.",
1870
+ "source pull": "Usage: tender-accounts source pull [--project PROJECT_ID] [--remote NAME] [--dry-run] [--json]\n\nClone the exact connected Tender-managed repository into an empty workspace, or fast-forward an existing clean matching checkout. The app is linked automatically and the short-lived Git credential is never persisted.\n\nExamples:\n tender-accounts source pull --project prj_... --dry-run --json\n tender-accounts source pull --project prj_... --json\n tender-accounts source pull --json",
1304
1871
  "source token": "Usage: tender-accounts source token [--ttl-seconds N] [--json]\n\nIssue a short-lived, repository-scoped Git credential. It is shown once and is never stored by the CLI.",
1305
1872
  "source push": "Usage: tender-accounts source push [--branch NAME] [--remote NAME] [--json]\n\nPush the exact committed revision with an ephemeral credential supplied only to the Git child process. Dirty working-tree changes are excluded.",
1306
1873
  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.",
@@ -1316,7 +1883,7 @@ function helpText(topic) {
1316
1883
  };
1317
1884
  if (topic && topics[topic])
1318
1885
  return topics[topic];
1319
- 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 source status Inspect Tender-managed Git and recent builds\n source connect Connect this linked project to managed Git\n source token Issue a short-lived repository credential\n source push Push the exact commit without persisting a token\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.`;
1886
+ 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 source status Inspect Tender-managed Git and recent builds\n source branches List exact managed branches and default policy\n source compare Compare two managed branches\n source connect Connect this linked project to managed Git\n source pull Clone or fast-forward the exact connected repository\n source token Issue a short-lived repository credential\n source push Push the exact commit without persisting a token\n source promote Fast-forward the default after successful preview checks\n source history Run guarded, archived source-history migration\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.`;
1320
1887
  }
1321
1888
  function normalizeError(error) {
1322
1889
  if (error instanceof CliApiError) {