@tokenoftrust/cli 1.4.0-rc.20 → 1.4.0-rc.21
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/bin/tot.mjs +52 -54
- package/package.json +6 -1
- package/src/activity.mjs +5 -4
- package/src/app-scaffold.mjs +2 -2
- package/src/auth.mjs +13 -5
- package/src/commands/accept.mjs +473 -50
- package/src/commands/app/dev.mjs +7 -3
- package/src/commands/app/index.mjs +2 -2
- package/src/commands/branches.mjs +1 -0
- package/src/commands/cleanup.mjs +2 -1
- package/src/commands/clone.mjs +51 -20
- package/src/commands/dev.mjs +30 -12
- package/src/commands/git-credential.mjs +180 -0
- package/src/commands/go-live.mjs +6 -2
- package/src/commands/grants.mjs +6 -4
- package/src/commands/link.mjs +2 -2
- package/src/commands/login.mjs +3 -4
- package/src/commands/pr.mjs +4 -3
- package/src/commands/preview.mjs +1 -1
- package/src/commands/rollback.mjs +6 -4
- package/src/commands/start.mjs +59 -11
- package/src/commands/submit.mjs +280 -51
- package/src/commands/sync.mjs +11 -0
- package/src/commands/validate.mjs +10 -4
- package/src/dev-heartbeat.mjs +2 -1
- package/src/errors.mjs +8 -4
- package/src/git-credential.mjs +185 -0
- package/src/mcp.mjs +6 -1
- package/src/oauth.mjs +12 -8
- package/src/obstacle-beacon.cjs +2 -2
- package/src/obstacle.mjs +1 -1
- package/src/plan.mjs +3 -3
- package/src/sample.mjs +3 -3
- package/src/validate.mjs +56 -0
- package/src/viewer-session.mjs +118 -0
|
@@ -58,6 +58,7 @@ Options:
|
|
|
58
58
|
|
|
59
59
|
/** Parse `tot branches` argv. Pure — unit-testable. */
|
|
60
60
|
export function parseBranchesArgs(argv) {
|
|
61
|
+
/** @type {{ tenant: string|null, repo: string|null, staleAfterDays: number|null, mcp: string|null, identity: string|null, json: boolean, help: boolean }} */
|
|
61
62
|
const a = {
|
|
62
63
|
tenant: null,
|
|
63
64
|
repo: null,
|
package/src/commands/cleanup.mjs
CHANGED
|
@@ -65,6 +65,7 @@ Options:
|
|
|
65
65
|
|
|
66
66
|
/** Parse `tot cleanup` argv. Pure — unit-testable. */
|
|
67
67
|
export function parseCleanupArgs(argv) {
|
|
68
|
+
/** @type {{ tenant: string|null, repo: string|null, staleAfterDays: number|null, ref: string|null, dryRun: boolean, yes: boolean, mcp: string|null, identity: string|null, help: boolean }} */
|
|
68
69
|
const a = {
|
|
69
70
|
tenant: null,
|
|
70
71
|
repo: null,
|
|
@@ -206,7 +207,7 @@ export async function run(argv, ctx) {
|
|
|
206
207
|
|
|
207
208
|
const planLines = planForAction({
|
|
208
209
|
action: "cleanup",
|
|
209
|
-
tenant: tenant || repo,
|
|
210
|
+
tenant: /** @type {string} */ (tenant || repo),
|
|
210
211
|
refs: eligible.map((b) => ({ ref: b.ref, sha: b.sha, reason: b.reason })),
|
|
211
212
|
});
|
|
212
213
|
const { confirmed, reason } = await printPlanAndConfirm(planLines, {
|
package/src/commands/clone.mjs
CHANGED
|
@@ -54,12 +54,14 @@ import { offerSignIn } from "./login.mjs";
|
|
|
54
54
|
import { CliError, fail, formatError } from "../errors.mjs";
|
|
55
55
|
import { writeNvmrc } from "../sample.mjs";
|
|
56
56
|
import { emitObstacle } from "../obstacle.mjs";
|
|
57
|
+
import { CREDENTIAL_HELPER, splitAuthedRemote, basicAuthExtraHeader } from "../git-credential.mjs";
|
|
57
58
|
|
|
58
59
|
const execFileP = promisify(execFile);
|
|
59
60
|
|
|
60
61
|
const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
|
|
61
62
|
|
|
62
63
|
function parseArgs(argv) {
|
|
64
|
+
/** @type {{ tenant: string|null, dir: string|null, tag: string, pr: string|null, remoteOnly: boolean, mcp: string|null, printRemote: boolean, help: boolean }} */
|
|
63
65
|
const a = {
|
|
64
66
|
tenant: null,
|
|
65
67
|
dir: null,
|
|
@@ -244,9 +246,9 @@ export async function run(argv, ctx) {
|
|
|
244
246
|
|
|
245
247
|
if (res.cloned) {
|
|
246
248
|
console.log(`+ cloned. HEAD: ${res.head}`);
|
|
247
|
-
console.log(`\nYour local working clone is at ${res.dir}
|
|
249
|
+
console.log(`\nYour local working clone is at ${res.dir}.`);
|
|
248
250
|
console.log(` cd ${res.dir} && tot dev # run it locally with save→reload`);
|
|
249
|
-
console.log(` (
|
|
251
|
+
console.log(` (origin has no token — \`tot\` mints one fresh at fetch/push time)`);
|
|
250
252
|
return 0;
|
|
251
253
|
}
|
|
252
254
|
|
|
@@ -323,17 +325,38 @@ export async function checkoutTenant(client, { tenant, tag = "main", cloneDir =
|
|
|
323
325
|
}
|
|
324
326
|
|
|
325
327
|
/**
|
|
326
|
-
* git clone the authenticated remote into `dir
|
|
327
|
-
*
|
|
328
|
-
*
|
|
329
|
-
* `
|
|
330
|
-
*
|
|
328
|
+
* git clone the authenticated remote into `dir` — WITHOUT ever writing the
|
|
329
|
+
* live token to `.git/config` (unit u10). The MCP mints `gitRemote` as a
|
|
330
|
+
* basic-auth URL (`user:token@host`); rather than passing that straight to
|
|
331
|
+
* `git clone` (which records exactly the URL it was given as `origin`, token
|
|
332
|
+
* and all — the pre-u10 shape this fixes), the token is handed to git
|
|
333
|
+
* EPHEMERALLY via a one-shot `http.extraheader` (same mechanism
|
|
334
|
+
* pushPreviewRef in submit.mjs uses for a push) while the clone SOURCE is
|
|
335
|
+
* already the tokenless public URL — so `origin` comes out tokenless from
|
|
336
|
+
* the very first commit. The new checkout's `credential.helper` is then
|
|
337
|
+
* configured to `tot git-credential`, so every later fetch/push mints a
|
|
338
|
+
* fresh token through the CLI's own login session instead of ever needing
|
|
339
|
+
* one persisted on disk (a later clone no longer needs to "rotate" anything
|
|
340
|
+
* — the old flow's `.git/config` token is simply never written).
|
|
341
|
+
*
|
|
342
|
+
* Runs git as a NON-BLOCKING child process (promisified execFile) so the
|
|
343
|
+
* clone doesn't stall the Node event loop — `tot start` runs this inside a
|
|
344
|
+
* `Promise.all([...])` alongside the renderer prefetch, and a synchronous
|
|
345
|
+
* clone would serialize what's meant to overlap. Throws CliError on failure.
|
|
331
346
|
*/
|
|
332
347
|
async function cloneRepo(gitRemote, dir, redact) {
|
|
333
348
|
const git = async (cargs) => (await execFileP("git", cargs)).stdout.toString();
|
|
334
349
|
console.log(`+ git clone → ${dir}`);
|
|
350
|
+
const cred = splitAuthedRemote(gitRemote);
|
|
351
|
+
const cloneArgs = cred
|
|
352
|
+
? [
|
|
353
|
+
"-c", `http.extraheader=${basicAuthExtraHeader(cred.username, cred.token)}`,
|
|
354
|
+
"-c", "credential.helper=",
|
|
355
|
+
"clone", cred.publicUrl, dir,
|
|
356
|
+
]
|
|
357
|
+
: ["clone", gitRemote, dir]; // no embedded token (e.g. already public) — clone as given
|
|
335
358
|
try {
|
|
336
|
-
await git(
|
|
359
|
+
await git(cloneArgs);
|
|
337
360
|
} catch (e) {
|
|
338
361
|
// Beacon the cockpit before we surface the error — covers this path for both
|
|
339
362
|
// `tot clone` and `tot start` (which clones through here). Awaited so the
|
|
@@ -343,6 +366,12 @@ async function cloneRepo(gitRemote, dir, redact) {
|
|
|
343
366
|
next: `check the target dir is empty and you can reach the remote, then re-run`,
|
|
344
367
|
});
|
|
345
368
|
}
|
|
369
|
+
try {
|
|
370
|
+
await git(["-C", dir, "config", "--local", "credential.helper", CREDENTIAL_HELPER]);
|
|
371
|
+
} catch {
|
|
372
|
+
// Best-effort — a checkout missing the helper still WORKS (the token just
|
|
373
|
+
// isn't self-healing yet); the next `tot preview`/`tot sync` migrates it.
|
|
374
|
+
}
|
|
346
375
|
const head = (await git(["-C", dir, "log", "-1", "--oneline"])).trim();
|
|
347
376
|
writeNvmrc(dir); // version-manager hooks land on a supported Node on cd
|
|
348
377
|
return { dir, head };
|
|
@@ -446,7 +475,7 @@ export async function cloneRepoAtPrRef(gitRemote, dir, pr, redact, deps = {}) {
|
|
|
446
475
|
* @returns {{ message: string, next: string }|null}
|
|
447
476
|
*/
|
|
448
477
|
export function readCredentialError(minted) {
|
|
449
|
-
const c = minted && typeof minted === "object" && !Array.isArray(minted) ? minted : null;
|
|
478
|
+
const c = /** @type {any} */ (minted && typeof minted === "object" && !Array.isArray(minted) ? minted : null);
|
|
450
479
|
if (c && Array.isArray(c.repos) && c.repos.some((r) => r && r.gitRemote)) return null;
|
|
451
480
|
const msg =
|
|
452
481
|
(c && (c.message || (typeof c.error === "string" ? c.error : c.error?.message))) ||
|
|
@@ -493,7 +522,7 @@ export function readCredentialError(minted) {
|
|
|
493
522
|
* @returns {{ message: string, next: string }|null}
|
|
494
523
|
*/
|
|
495
524
|
export function checkoutError(checkout) {
|
|
496
|
-
const c = checkout && typeof checkout === "object" && !Array.isArray(checkout) ? checkout : null;
|
|
525
|
+
const c = /** @type {any} */ (checkout && typeof checkout === "object" && !Array.isArray(checkout) ? checkout : null);
|
|
497
526
|
if (c && c.gitRemote) return null; // a usable checkout — never an error
|
|
498
527
|
const msg =
|
|
499
528
|
(c && (c.message || (typeof c.error === "string" ? c.error : c.error?.message))) ||
|
|
@@ -536,7 +565,8 @@ export function checkoutError(checkout) {
|
|
|
536
565
|
* @returns {Array<{ id: string, name: string, raw: any }>}
|
|
537
566
|
*/
|
|
538
567
|
export function normalizeStores(list) {
|
|
539
|
-
const
|
|
568
|
+
const l = /** @type {any} */ (list);
|
|
569
|
+
const rows = Array.isArray(l) ? l : l?.clients || l?.tenants || [];
|
|
540
570
|
if (!Array.isArray(rows)) return [];
|
|
541
571
|
return rows
|
|
542
572
|
.map((r) => ({
|
|
@@ -562,18 +592,19 @@ export function normalizeStores(list) {
|
|
|
562
592
|
*/
|
|
563
593
|
export function storeListError(list) {
|
|
564
594
|
if (list == null || typeof list !== "object" || Array.isArray(list)) return null;
|
|
595
|
+
const l = /** @type {any} */ (list);
|
|
565
596
|
// A resolvable store array present → it succeeded, never an error.
|
|
566
|
-
if (Array.isArray(
|
|
597
|
+
if (Array.isArray(l.clients) || Array.isArray(l.tenants)) return null;
|
|
567
598
|
const msg =
|
|
568
|
-
|
|
569
|
-
(typeof
|
|
599
|
+
l.message ||
|
|
600
|
+
(typeof l.error === "string" ? l.error : l.error?.message) ||
|
|
570
601
|
null;
|
|
571
|
-
if (
|
|
572
|
-
if (typeof
|
|
573
|
-
return msg || `the store list request returned status "${
|
|
602
|
+
if (l.isError) return msg || "the store list request returned an error";
|
|
603
|
+
if (typeof l.status === "string" && !/^(ok|success)$/i.test(l.status)) {
|
|
604
|
+
return msg || `the store list request returned status "${l.status}"`;
|
|
574
605
|
}
|
|
575
|
-
if (
|
|
576
|
-
if (typeof
|
|
606
|
+
if (l.error) return msg || "the store list request returned an error";
|
|
607
|
+
if (typeof l.raw === "string" && l.raw.trim()) return l.raw.trim();
|
|
577
608
|
return null;
|
|
578
609
|
}
|
|
579
610
|
|
|
@@ -592,7 +623,7 @@ export function storeListError(list) {
|
|
|
592
623
|
* @returns {{ brokerStatus: string|null, nextAction: string|null }}
|
|
593
624
|
*/
|
|
594
625
|
export function brokerRemediation(list) {
|
|
595
|
-
const c = list && typeof list === "object" && !Array.isArray(list) ? list : null;
|
|
626
|
+
const c = /** @type {any} */ (list && typeof list === "object" && !Array.isArray(list) ? list : null);
|
|
596
627
|
const brokerStatus = c && typeof c.brokerStatus === "string" ? c.brokerStatus : null;
|
|
597
628
|
const nextAction =
|
|
598
629
|
c && typeof c.nextAction === "string" && c.nextAction.trim() ? c.nextAction.trim() : null;
|
package/src/commands/dev.mjs
CHANGED
|
@@ -409,7 +409,7 @@ export function printSampleBanner({ url }, mode = "sample") {
|
|
|
409
409
|
* Exported so sample/tests can drive the selection directly.
|
|
410
410
|
* @param {ReturnType<typeof parseArgs>} args
|
|
411
411
|
* @param {{ client?: any }} [opts] an already-authenticated MCP client (entitled path)
|
|
412
|
-
* @returns {Promise<{kind:
|
|
412
|
+
* @returns {Promise<{kind:string,version:string,url:string,strip:number,cacheKey:string,integrity:string|null}>}
|
|
413
413
|
*/
|
|
414
414
|
export async function resolveRendererSource(args, { client } = {}) {
|
|
415
415
|
if (args.sample) return resolvePublicRendererSource(args);
|
|
@@ -576,6 +576,11 @@ function compareStableAsc(a, b) {
|
|
|
576
576
|
* override with `--renderer-version` / TOT_RUNNER_VERSION. Package/registry
|
|
577
577
|
* overridable via env for testing.
|
|
578
578
|
*/
|
|
579
|
+
/**
|
|
580
|
+
* @param {any} args
|
|
581
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
582
|
+
* @param {{ declaredVersion?: string|null }} [opts]
|
|
583
|
+
*/
|
|
579
584
|
export async function resolvePublicRendererSource(args, env = process.env, { declaredVersion = null } = {}) {
|
|
580
585
|
const pkg = env.TOT_RUNNER_PACKAGE || PUBLIC_RUNNER_PACKAGE;
|
|
581
586
|
const registry = (env.TOT_NPM_REGISTRY || DEFAULT_NPM_REGISTRY).replace(/\/$/, "");
|
|
@@ -635,6 +640,7 @@ export async function resolvePublicRendererSource(args, env = process.env, { dec
|
|
|
635
640
|
* developer entitlement (same gate as the Docker pull token). Reuses an
|
|
636
641
|
* already-authenticated `client` when provided (C1/F3), else establishes its own.
|
|
637
642
|
*/
|
|
643
|
+
/** @param {any} args @param {{ client?: any }} [opts] */
|
|
638
644
|
export async function resolveEntitledRendererSource(args, { client: providedClient } = {}) {
|
|
639
645
|
const baseUrl = args.mcp || process.env.MCP_BASE_URL || process.env.TOT_MCP_URL || DEFAULT_MCP_URL;
|
|
640
646
|
const client = providedClient || createMcpClient(baseUrl);
|
|
@@ -674,6 +680,8 @@ export async function resolveEntitledRendererSource(args, { client: providedClie
|
|
|
674
680
|
* instead of paying for a second client.initialize()+establishSession() — same
|
|
675
681
|
* pattern as ensureRegistryLogin's `providedClient`). `tot dev` standalone
|
|
676
682
|
* omits it and this establishes its own, as before.
|
|
683
|
+
* @param {any} args
|
|
684
|
+
* @param {{ client?: any }} [opts]
|
|
677
685
|
* @returns {Promise<string>} the cached, installed runner tree's root directory.
|
|
678
686
|
*/
|
|
679
687
|
export async function ensureRendererArtifact(args, { client: providedClient } = {}) {
|
|
@@ -854,11 +862,11 @@ export function probeRunnerVersion(runnerDir, { timeoutMs = 4000 } = {}) {
|
|
|
854
862
|
* @returns {Promise<string>} the runner tree's root directory (has scripts/tot-dev.mjs).
|
|
855
863
|
*/
|
|
856
864
|
export async function ensureSampleRenderer(args, ctx, { env = process.env, cacheRoot = RENDERER_CACHE_ROOT } = {}) {
|
|
857
|
-
const src = resolveLocalRendererSource({
|
|
865
|
+
const src = /** @type {any} */ (resolveLocalRendererSource({
|
|
858
866
|
env,
|
|
859
867
|
mode: ctx?.mode,
|
|
860
868
|
repoRoot: ctx?.repoRoot,
|
|
861
|
-
});
|
|
869
|
+
}));
|
|
862
870
|
|
|
863
871
|
if (src.kind === "dir") {
|
|
864
872
|
if (!existsSync(join(src.dir, "scripts", "tot-dev.mjs"))) {
|
|
@@ -1048,7 +1056,7 @@ export async function installRunnerTarball(
|
|
|
1048
1056
|
// of stopping a first run at an error only `rm -rf` folklore could clear.
|
|
1049
1057
|
// Bounded to one retry so a genuinely-broken source still fails loudly.
|
|
1050
1058
|
for (let attempt = 1; ; attempt++) {
|
|
1051
|
-
const archivePath = isUrl ? join(tmpdir(), `tot-renderer-${process.pid}-${attempt}.tar.gz`) : localSource;
|
|
1059
|
+
const archivePath = /** @type {string} */ (isUrl ? join(tmpdir(), `tot-renderer-${process.pid}-${attempt}.tar.gz`) : localSource);
|
|
1052
1060
|
const stagingDir = `${runnerDir}.staging-${process.pid}`;
|
|
1053
1061
|
try {
|
|
1054
1062
|
if (isUrl) {
|
|
@@ -1118,7 +1126,7 @@ export async function installRunnerTarball(
|
|
|
1118
1126
|
exitCode: 2,
|
|
1119
1127
|
},
|
|
1120
1128
|
);
|
|
1121
|
-
err.permanent = true;
|
|
1129
|
+
/** @type {any} */ (err).permanent = true;
|
|
1122
1130
|
throw err;
|
|
1123
1131
|
}
|
|
1124
1132
|
writeCacheMarker(runnerDir, { version, integrity: contentId });
|
|
@@ -1270,7 +1278,7 @@ async function downloadFile(url, destPath) {
|
|
|
1270
1278
|
if (!res.ok || !res.body) {
|
|
1271
1279
|
throw new Error(`download failed: HTTP ${res.status} ${res.statusText}`);
|
|
1272
1280
|
}
|
|
1273
|
-
await pipeline(Readable.fromWeb(res.body), createWriteStream(destPath));
|
|
1281
|
+
await pipeline(Readable.fromWeb(/** @type {any} */ (res.body)), createWriteStream(destPath));
|
|
1274
1282
|
}
|
|
1275
1283
|
|
|
1276
1284
|
/**
|
|
@@ -1319,7 +1327,8 @@ export function ensureCorepackPnpm(runnerDir, { logPath, spawnFn = spawnSync } =
|
|
|
1319
1327
|
for (const args of [["enable"], ["prepare", pm, "--activate"]]) {
|
|
1320
1328
|
const r = spawnFn("corepack", args, { stdio: ["ignore", fd ?? "ignore", fd ?? "ignore"] });
|
|
1321
1329
|
if (fd !== null && (r.error || r.status !== 0)) {
|
|
1322
|
-
|
|
1330
|
+
const rerr = /** @type {any} */ (r.error);
|
|
1331
|
+
writeSync(fd, `[tot] corepack ${args.join(" ")} → ${rerr?.code || rerr?.message || `exit ${r.status}`}\n`);
|
|
1323
1332
|
}
|
|
1324
1333
|
}
|
|
1325
1334
|
} finally {
|
|
@@ -1363,6 +1372,10 @@ function spawnAsyncResult(cmd, args, opts = {}) {
|
|
|
1363
1372
|
});
|
|
1364
1373
|
}
|
|
1365
1374
|
|
|
1375
|
+
/**
|
|
1376
|
+
* @param {string} runnerDir
|
|
1377
|
+
* @param {{ logPath?: string, spawnFn?: (cmd: string, args: string[], opts?: any) => any }} [opts]
|
|
1378
|
+
*/
|
|
1366
1379
|
export async function runPnpmInstall(runnerDir, { logPath, spawnFn = spawnAsyncResult } = {}) {
|
|
1367
1380
|
const fd = logPath ? openSync(logPath, "a") : null;
|
|
1368
1381
|
const installArgs = ["install", "--config.dangerouslyAllowAllBuilds=true"];
|
|
@@ -1400,7 +1413,7 @@ export async function runPnpmInstall(runnerDir, { logPath, spawnFn = spawnAsyncR
|
|
|
1400
1413
|
stdio: ["ignore", fd ?? "ignore", fd ?? "ignore"],
|
|
1401
1414
|
});
|
|
1402
1415
|
if (r.status === 0) return; // installed
|
|
1403
|
-
if (r.error?.code === "ENOENT") {
|
|
1416
|
+
if (/** @type {any} */ (r.error)?.code === "ENOENT") {
|
|
1404
1417
|
// This launcher isn't on the machine — record it and try the next one.
|
|
1405
1418
|
if (fd !== null) writeSync(fd, `[tot] ${cmd} not found (ENOENT) — trying the next launcher\n`);
|
|
1406
1419
|
continue;
|
|
@@ -1425,7 +1438,7 @@ export async function runPnpmInstall(runnerDir, { logPath, spawnFn = spawnAsyncR
|
|
|
1425
1438
|
(logPath ? `\n details: ${logPath}` : ""),
|
|
1426
1439
|
{ next: "install pnpm with `npm i -g pnpm` (or `corepack enable`), then re-run `tot start`" },
|
|
1427
1440
|
);
|
|
1428
|
-
err.permanent = true;
|
|
1441
|
+
/** @type {any} */ (err).permanent = true;
|
|
1429
1442
|
throw err;
|
|
1430
1443
|
} finally {
|
|
1431
1444
|
if (fd !== null) closeSync(fd);
|
|
@@ -1470,6 +1483,10 @@ function pnpmFailureHint(logPath) {
|
|
|
1470
1483
|
* fs-watch HMR. Returns a handle shaped like spawnDevContainer's, so run()'s
|
|
1471
1484
|
* auto-open-browser logic works unchanged for either runtime. Exported (and
|
|
1472
1485
|
* `stdio` overridable) so `tot start` can pipe the logs instead of inheriting.
|
|
1486
|
+
* @param {string} runnerDir
|
|
1487
|
+
* @param {string} workspace
|
|
1488
|
+
* @param {string} port
|
|
1489
|
+
* @param {{ stdio?: any, env?: Record<string, any> }} [opts]
|
|
1473
1490
|
*/
|
|
1474
1491
|
export function spawnNativeDev(runnerDir, workspace, port, { stdio = "inherit", env = {} } = {}) {
|
|
1475
1492
|
const script = join(runnerDir, "scripts", "tot-dev.mjs");
|
|
@@ -1490,7 +1507,7 @@ export function spawnNativeDev(runnerDir, workspace, port, { stdio = "inherit",
|
|
|
1490
1507
|
[script, "--workspace", workspace, "--port", port],
|
|
1491
1508
|
{ cwd: runnerDir, stdio: stdioArr, env: mergedEnv },
|
|
1492
1509
|
);
|
|
1493
|
-
const handle = { child, exited: false, done: null };
|
|
1510
|
+
const handle = { child, exited: false, done: /** @type {any} */ (null) };
|
|
1494
1511
|
handle.done = new Promise((resolvePromise) => {
|
|
1495
1512
|
child.on("exit", (code) => {
|
|
1496
1513
|
handle.exited = true;
|
|
@@ -1645,9 +1662,9 @@ export async function spawnDevContainer(plan, args, { stdio = "inherit" } = {})
|
|
|
1645
1662
|
|
|
1646
1663
|
const stdioArr =
|
|
1647
1664
|
stdio === "piped" ? ["ignore", "pipe", "pipe"] : ["inherit", "inherit", "inherit"];
|
|
1648
|
-
const child = spawn("docker", plan.dockerArgs, { stdio: stdioArr });
|
|
1665
|
+
const child = spawn("docker", plan.dockerArgs, { stdio: /** @type {any} */ (stdioArr) });
|
|
1649
1666
|
|
|
1650
|
-
const handle = { child, exited: false, done: null };
|
|
1667
|
+
const handle = { child, exited: false, done: /** @type {any} */ (null) };
|
|
1651
1668
|
handle.done = new Promise((resolvePromise) => {
|
|
1652
1669
|
child.on("exit", (code) => {
|
|
1653
1670
|
handle.exited = true;
|
|
@@ -1687,6 +1704,7 @@ export function isPrivateRegistryImage(image) {
|
|
|
1687
1704
|
* its own session and overlaps this with the checkout clone instead of paying
|
|
1688
1705
|
* for a second client.initialize()+establishSession() serially afterward).
|
|
1689
1706
|
* `tot dev` standalone omits it and this establishes its own, as before.
|
|
1707
|
+
* @param {string} image @param {any} args @param {{ client?: any }} [opts]
|
|
1690
1708
|
*/
|
|
1691
1709
|
export async function ensureRegistryLogin(image, args, { client: providedClient } = {}) {
|
|
1692
1710
|
const registry = String(image).split("/")[0];
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `tot git-credential` — git's own credential-helper protocol (see `git help
|
|
3
|
+
* gitcredentials`), implemented over the developer's cached `tot login`
|
|
4
|
+
* session (unit u10, workstream tot-merge-conflict-resolution-ux). `tot
|
|
5
|
+
* clone` configures a fresh checkout's `credential.helper` to run this (see
|
|
6
|
+
* ../git-credential.mjs's CREDENTIAL_HELPER), so `git fetch`/`git push`/`git
|
|
7
|
+
* pull` — run DIRECTLY by the developer, not just through `tot preview` —
|
|
8
|
+
* transparently mint a fresh forge token instead of ever needing one
|
|
9
|
+
* persisted in `.git/config`. This is the fix for the "the panel-prescribed
|
|
10
|
+
* `git fetch origin` fails even after `tot login`" dead-end: `tot login`
|
|
11
|
+
* only ever refreshed the CLI's OWN MCP session, never the token baked into
|
|
12
|
+
* a checkout's remote URL at clone time.
|
|
13
|
+
*
|
|
14
|
+
* git invokes this with ONE positional arg (`get`/`store`/`erase`) and the
|
|
15
|
+
* request on stdin (key=value lines, blank-line/EOF terminated). Only `get`
|
|
16
|
+
* does real work — this CLI never persists a forge credential of its own
|
|
17
|
+
* beyond the short-lived cache in ../git-credential.mjs, so `store`/`erase`
|
|
18
|
+
* are no-ops (git calls them after a successful/failed auth respectively; we
|
|
19
|
+
* just drain stdin and exit 0, the correct behavior for a stateless helper).
|
|
20
|
+
*
|
|
21
|
+
* FAILS SILENT, NEVER LOUD: `get` prints NOTHING and exits non-zero on any
|
|
22
|
+
* problem (not signed in, MCP unreachable, cwd isn't a recognizable tenant
|
|
23
|
+
* checkout) — git then falls through to its next configured helper or its
|
|
24
|
+
* own prompt, exactly as if this helper weren't configured. A stack trace or
|
|
25
|
+
* a malformed credential line here would otherwise corrupt EVERY git
|
|
26
|
+
* operation in the checkout. It also NEVER triggers an interactive sign-in —
|
|
27
|
+
* this runs as a non-interactive subprocess of `git`, so a missing session
|
|
28
|
+
* fails through rather than trying to open a browser mid-`git fetch`.
|
|
29
|
+
*
|
|
30
|
+
* Dependency-free (global fetch + `git`, via the same MCP client + auth
|
|
31
|
+
* module every other command uses).
|
|
32
|
+
*/
|
|
33
|
+
import { readFileSync } from "node:fs";
|
|
34
|
+
import { execFileSync } from "node:child_process";
|
|
35
|
+
import { createMcpClient } from "../mcp.mjs";
|
|
36
|
+
import { establishSession } from "../auth.mjs";
|
|
37
|
+
import { checkoutTenant } from "./clone.mjs";
|
|
38
|
+
import { detectContext } from "../context.mjs";
|
|
39
|
+
import { repoNameFromRemote, tagFromRepoName } from "./submit.mjs";
|
|
40
|
+
import {
|
|
41
|
+
parseCredentialInput, formatCredentialOutput, splitAuthedRemote,
|
|
42
|
+
credentialCachePath, readCachedCredential, writeCachedCredential,
|
|
43
|
+
} from "../git-credential.mjs";
|
|
44
|
+
|
|
45
|
+
const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
|
|
46
|
+
|
|
47
|
+
/** Does `remoteUrl`'s host equal `host` (case-insensitive)? An unparseable or
|
|
48
|
+
* missing remote URL never matches — fail closed. Exported for testing. */
|
|
49
|
+
export function hostMatches(remoteUrl, host) {
|
|
50
|
+
if (!remoteUrl || !host) return false;
|
|
51
|
+
try {
|
|
52
|
+
// scp-like remotes (git@host:owner/repo.git) have no scheme — synthesize one so URL can parse the host.
|
|
53
|
+
const normalized = /^[^/]+@[^/:]+:/.test(remoteUrl) ? `ssh://${remoteUrl.replace(":", "/")}` : remoteUrl;
|
|
54
|
+
return new URL(normalized).host.toLowerCase() === host.toLowerCase();
|
|
55
|
+
} catch {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Read the credential request git writes to stdin — blocking is correct
|
|
61
|
+
* here: git writes the request then closes its end, so this returns as
|
|
62
|
+
* soon as it's fully sent. Never blocks on an interactive terminal (git
|
|
63
|
+
* NEVER attaches a TTY to a credential helper's stdin — only a human
|
|
64
|
+
* poking at this command directly by hand would) and never throws (an
|
|
65
|
+
* unreadable/absent stdin is treated as an empty request). Exported so a
|
|
66
|
+
* test can inject a canned request instead of touching real fd 0. */
|
|
67
|
+
export function readStdin() {
|
|
68
|
+
if (process.stdin.isTTY) return "";
|
|
69
|
+
try {
|
|
70
|
+
return readFileSync(0, "utf8");
|
|
71
|
+
} catch {
|
|
72
|
+
return "";
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Mint (or reuse a cached) forge credential for the tenant the CURRENT
|
|
78
|
+
* directory checks out — the same `tenant_checkout` MCP call `tot clone`
|
|
79
|
+
* itself uses, so the credential is exactly as push-capable. Returns null on
|
|
80
|
+
* ANY failure (wrong dir, not signed in, MCP unreachable) rather than
|
|
81
|
+
* throwing — `get` treats null as "say nothing, exit non-zero".
|
|
82
|
+
*
|
|
83
|
+
* `createClient`/`establish`/`checkout` are injected (default to the real MCP
|
|
84
|
+
* client + auth + clone.mjs's checkoutTenant) purely so this is testable
|
|
85
|
+
* without a live MCP — same DI shape as chooseChangeId's injected `mint` /
|
|
86
|
+
* resolveDeveloperSession's injected `fetchImpl` elsewhere in this CLI.
|
|
87
|
+
* SCOPED TO THE CHECKOUT'S OWN REMOTE: `expectedHost` (git's requested host,
|
|
88
|
+
* from the credential-helper request) is checked against the host of this
|
|
89
|
+
* checkout's `origin` remote before a credential is ever minted or returned
|
|
90
|
+
* — a `get` for any OTHER host returns null (silent fail), never handing the
|
|
91
|
+
* tenant's forge token to a host this checkout doesn't itself push to. This
|
|
92
|
+
* matters because `credential.helper` is invoked per-URL by git, and a
|
|
93
|
+
* globally-scoped helper (or a checkout with a submodule / unrelated remote)
|
|
94
|
+
* must not become a way to exfiltrate the token to an arbitrary host.
|
|
95
|
+
* @param {{
|
|
96
|
+
* env?: NodeJS.ProcessEnv, cwd?: string, expectedHost?: string|null,
|
|
97
|
+
* createClient?: typeof createMcpClient,
|
|
98
|
+
* establish?: typeof establishSession,
|
|
99
|
+
* checkout?: typeof checkoutTenant,
|
|
100
|
+
* }} [opts]
|
|
101
|
+
* @returns {Promise<{username:string,password:string}|null>}
|
|
102
|
+
*/
|
|
103
|
+
export async function mintOrCacheCredential({
|
|
104
|
+
env = process.env, cwd = process.cwd(), expectedHost = null,
|
|
105
|
+
createClient = createMcpClient, establish = establishSession, checkout = checkoutTenant,
|
|
106
|
+
} = {}) {
|
|
107
|
+
const ctx = detectContext(cwd);
|
|
108
|
+
if (ctx.mode !== "checkout" || !ctx.tenant) return null;
|
|
109
|
+
|
|
110
|
+
const git = (cargs) =>
|
|
111
|
+
execFileSync("git", ["-C", ctx.workspacePath, ...cargs], { stdio: ["ignore", "pipe", "pipe"] }).toString();
|
|
112
|
+
let originUrl = null;
|
|
113
|
+
let repoName = null;
|
|
114
|
+
try {
|
|
115
|
+
originUrl = git(["remote", "get-url", "origin"]).trim();
|
|
116
|
+
repoName = repoNameFromRemote(originUrl);
|
|
117
|
+
} catch {
|
|
118
|
+
/* fall through — tagFromRepoName degrades to "main" on a null repo name */
|
|
119
|
+
}
|
|
120
|
+
if (expectedHost && !hostMatches(originUrl, expectedHost)) return null;
|
|
121
|
+
const tag = tagFromRepoName(repoName, ctx.tenant);
|
|
122
|
+
|
|
123
|
+
const cachePath = credentialCachePath(ctx.tenant, tag, env);
|
|
124
|
+
const cached = readCachedCredential(cachePath);
|
|
125
|
+
if (cached) return { username: cached.username, password: cached.password };
|
|
126
|
+
|
|
127
|
+
const baseUrl = env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL;
|
|
128
|
+
const client = createClient(baseUrl);
|
|
129
|
+
try {
|
|
130
|
+
await establish(client, { env });
|
|
131
|
+
} catch {
|
|
132
|
+
return null; // not signed in (or session unrefreshable) — nothing this helper can do
|
|
133
|
+
}
|
|
134
|
+
try {
|
|
135
|
+
const res = await checkout(client, { tenant: ctx.tenant, tag, cloneDir: null });
|
|
136
|
+
const cred = splitAuthedRemote(res.gitRemote || "");
|
|
137
|
+
if (!cred) return null;
|
|
138
|
+
const fresh = { username: cred.username, password: cred.token };
|
|
139
|
+
writeCachedCredential(cachePath, fresh);
|
|
140
|
+
return fresh;
|
|
141
|
+
} catch {
|
|
142
|
+
return null; // MCP unreachable / tenant_checkout refused — say nothing, exit non-zero
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const USAGE = `tot git-credential — git credential-helper protocol over your \`tot login\` session
|
|
147
|
+
|
|
148
|
+
Configured automatically by \`tot clone\` (credential.helper = !tot git-credential)
|
|
149
|
+
in every checkout it creates — you should never need to run this by hand.
|
|
150
|
+
See \`git help gitcredentials\` for the protocol this implements.`;
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* @param {string[]} argv argv[0] is git's operation: get|store|erase
|
|
154
|
+
* @param {any} _ctx unused — this command derives its OWN context from cwd
|
|
155
|
+
* (mintOrCacheCredential), since git invokes it with the credentialed
|
|
156
|
+
* repo's directory as cwd, which may differ from wherever `tot` itself
|
|
157
|
+
* was dispatched from.
|
|
158
|
+
* @param {{ env?: NodeJS.ProcessEnv, cwd?: string, readStdin?: typeof readStdin }
|
|
159
|
+
* & Parameters<typeof mintOrCacheCredential>[0]} [opts]
|
|
160
|
+
*/
|
|
161
|
+
export async function run(argv, _ctx, opts = {}) {
|
|
162
|
+
const { env = process.env, readStdin: read = readStdin } = opts;
|
|
163
|
+
const op = argv[0];
|
|
164
|
+
if (!op || op === "--help" || op === "-h") {
|
|
165
|
+
console.log(USAGE);
|
|
166
|
+
return op ? 0 : 2;
|
|
167
|
+
}
|
|
168
|
+
// git always writes a request to stdin, even for store/erase — drain it either way
|
|
169
|
+
// so the subprocess exits cleanly instead of leaving git's write blocked on a full pipe.
|
|
170
|
+
const request = parseCredentialInput(read());
|
|
171
|
+
|
|
172
|
+
if (op !== "get") return 0; // store/erase: stateless, nothing to persist or drop.
|
|
173
|
+
|
|
174
|
+
const cred = await mintOrCacheCredential({ ...opts, expectedHost: request.host });
|
|
175
|
+
if (!cred) return 1; // silent — let git fall through to its next helper / its own prompt.
|
|
176
|
+
process.stdout.write(formatCredentialOutput({
|
|
177
|
+
protocol: request.protocol, host: request.host, username: cred.username, password: cred.password,
|
|
178
|
+
}));
|
|
179
|
+
return 0;
|
|
180
|
+
}
|
package/src/commands/go-live.mjs
CHANGED
|
@@ -196,6 +196,9 @@ export function renderReadiness({ appDomain, readiness, action }) {
|
|
|
196
196
|
const lines = ["", ` Apex ${action === "rollback" ? "rollback" : "cutover"} readiness for ${appDomain ?? "this store"}:`];
|
|
197
197
|
for (const c of readiness.checks) {
|
|
198
198
|
lines.push(` ${c.ok ? "✓" : "✗"} ${c.label}${c.detail ? ` — ${c.detail}` : ""}`);
|
|
199
|
+
// Blocked checks carry a "how to clear this" remedy from the server gate —
|
|
200
|
+
// the same guidance the admin panel shows, so the two surfaces never diverge.
|
|
201
|
+
if (!c.ok && c.remedy) lines.push(` → ${c.remedy}`);
|
|
199
202
|
}
|
|
200
203
|
if (action === "connect") {
|
|
201
204
|
lines.push(
|
|
@@ -217,6 +220,7 @@ export function renderReadiness({ appDomain, readiness, action }) {
|
|
|
217
220
|
* assuming it from the dispatch. Injectable delay/attempts. Returns the last
|
|
218
221
|
* normalised run status (may still be "dispatched" if CI is slow — reported honestly).
|
|
219
222
|
* @param {{ get:(path:string)=>Promise<any> }} http
|
|
223
|
+
* @param {{ attempts?: number, delayMs?: number, sleep?: Function }} [opts]
|
|
220
224
|
*/
|
|
221
225
|
export async function pollRunTerminal(http, { attempts = 8, delayMs = 2000, sleep } = {}) {
|
|
222
226
|
const wait = sleep || ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
@@ -391,7 +395,7 @@ function cap(s) {
|
|
|
391
395
|
* ship floor. Throws on a non-2xx with the server's error message (so the caller
|
|
392
396
|
* surfaces the real refusal). `fetchImpl` is injectable for tests.
|
|
393
397
|
* @param {string} base
|
|
394
|
-
* @param {{ token
|
|
398
|
+
* @param {{ token?:string, owner?:string, capability?:string, fetchImpl?:typeof fetch }} [auth]
|
|
395
399
|
*/
|
|
396
400
|
export function createStorefrontHttp(base, { token, owner, capability = "ship-on-behalf", fetchImpl } = {}) {
|
|
397
401
|
const root = base.replace(/\/+$/, "");
|
|
@@ -476,7 +480,7 @@ export async function run(argv, ctx) {
|
|
|
476
480
|
const http = createStorefrontHttp(base, { token, owner });
|
|
477
481
|
return await runGoLive(
|
|
478
482
|
http,
|
|
479
|
-
{ appDomain: owner, action: args.action, rehearsal: args.rehearsal, noOpen: args.noOpen },
|
|
483
|
+
{ appDomain: owner, action: /** @type {any} */ (args.action), rehearsal: args.rehearsal, noOpen: args.noOpen },
|
|
480
484
|
{ openUrl: (u) => openBrowser(u) },
|
|
481
485
|
);
|
|
482
486
|
}
|
package/src/commands/grants.mjs
CHANGED
|
@@ -61,9 +61,10 @@ function parseArgs(argv) {
|
|
|
61
61
|
* exp: number|null, revokedAt: string|null }> }}
|
|
62
62
|
*/
|
|
63
63
|
export function normalizeGrantRows(resp) {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
const
|
|
64
|
+
const rr = /** @type {any} */ (resp);
|
|
65
|
+
if (resp && typeof resp === "object" && !Array.isArray(resp) && Array.isArray(rr.grants)) {
|
|
66
|
+
const activeTenant = typeof rr.activeTenant === "string" ? rr.activeTenant : null;
|
|
67
|
+
const rows = rr.grants
|
|
67
68
|
.map((g) => {
|
|
68
69
|
const tenant = g?.tenant ?? g?.id ?? null;
|
|
69
70
|
return {
|
|
@@ -83,7 +84,7 @@ export function normalizeGrantRows(resp) {
|
|
|
83
84
|
|
|
84
85
|
// Fallback: client_list — store ids + environment + the selected marker only. No
|
|
85
86
|
// grant detail is knowable here (grantActive/capability/tier/exp/revokedAt stay null).
|
|
86
|
-
const clients = Array.isArray(resp) ? resp :
|
|
87
|
+
const clients = Array.isArray(resp) ? resp : rr?.clients || rr?.tenants || [];
|
|
87
88
|
const rows = (Array.isArray(clients) ? clients : [])
|
|
88
89
|
.map((c) => ({
|
|
89
90
|
tenant: c?.tenant ?? c?.id ?? c?.clientId ?? c?.appDomain ?? null,
|
|
@@ -147,6 +148,7 @@ export function formatExpiry(exp, { now = Date.now() } = {}) {
|
|
|
147
148
|
export function describeGrantRow(row, { now = Date.now() } = {}) {
|
|
148
149
|
const status = grantStatus(row, { now });
|
|
149
150
|
if (status === "unknown") return "grant detail unavailable (server introspection not enabled)";
|
|
151
|
+
/** @type {string[]} */
|
|
150
152
|
const parts = [status];
|
|
151
153
|
if (row.capability) parts.push(`capability=${row.capability}`);
|
|
152
154
|
if (row.tier) parts.push(`tier=${row.tier}`);
|
package/src/commands/link.mjs
CHANGED
|
@@ -54,7 +54,7 @@ Run this when \`tot whoami\` / \`tot start\` say your identity isn't linked yet.
|
|
|
54
54
|
* @returns {{ authUrl: string|null, pollHandle: string|null }}
|
|
55
55
|
*/
|
|
56
56
|
export function linkBeginFields(res) {
|
|
57
|
-
const c = res && typeof res === "object" && !Array.isArray(res) ? res : {};
|
|
57
|
+
const c = /** @type {any} */ (res && typeof res === "object" && !Array.isArray(res) ? res : {});
|
|
58
58
|
const authUrl =
|
|
59
59
|
c.authUrl ||
|
|
60
60
|
c.url ||
|
|
@@ -77,7 +77,7 @@ export function linkBeginFields(res) {
|
|
|
77
77
|
* @returns {"linked"|"pending"|string}
|
|
78
78
|
*/
|
|
79
79
|
export function linkPollStatus(res) {
|
|
80
|
-
const c = res && typeof res === "object" && !Array.isArray(res) ? res : {};
|
|
80
|
+
const c = /** @type {any} */ (res && typeof res === "object" && !Array.isArray(res) ? res : {});
|
|
81
81
|
if (c.linked === true || c.done === true || c.complete === true) return "linked";
|
|
82
82
|
const raw =
|
|
83
83
|
(typeof c.status === "string" && c.status) || (typeof c.state === "string" && c.state) || "";
|
package/src/commands/login.mjs
CHANGED
|
@@ -120,7 +120,7 @@ After signing in, run \`tot whoami\` to confirm, then \`tot clone\` / \`tot subm
|
|
|
120
120
|
* without duplicating this.
|
|
121
121
|
* @returns {Promise<object>} the credentials written to disk.
|
|
122
122
|
*/
|
|
123
|
-
export async function loginAndCache(mcpUrl, env = process.env, { log = () => {}, device = false } = {}) {
|
|
123
|
+
export async function loginAndCache(mcpUrl, env = process.env, { log = /** @type {(m?: string) => void} */ (() => {}), device = false } = {}) {
|
|
124
124
|
const path = defaultCredentialsPath(env);
|
|
125
125
|
const prior = readCredentials(path);
|
|
126
126
|
const clientId = prior && prior.mcpUrl === mcpUrl ? prior.clientId : undefined;
|
|
@@ -196,11 +196,10 @@ export async function offerSignIn(mcpUrl, env = process.env, {
|
|
|
196
196
|
* `log` carries the fingerprint + "waiting for approval" lines to the terminal.
|
|
197
197
|
* @returns {Promise<object>} the credentials written to disk.
|
|
198
198
|
*/
|
|
199
|
-
export async function redeemAndCache(mcpUrl, code, env = process.env, { log = () => {} } = {}) {
|
|
199
|
+
export async function redeemAndCache(mcpUrl, code, env = process.env, { log = /** @type {(m?: string) => void} */ (() => {}) } = {}) {
|
|
200
200
|
const path = defaultCredentialsPath(env);
|
|
201
201
|
const prior = readCredentials(path);
|
|
202
|
-
const
|
|
203
|
-
const creds = await rendezvousLoginFlow({ mcpUrl, clientId, code, log });
|
|
202
|
+
const creds = await rendezvousLoginFlow({ mcpUrl, code, log });
|
|
204
203
|
const merged = mergeActivityBridge(prior, mcpUrl, creds);
|
|
205
204
|
writeCredentials(path, merged);
|
|
206
205
|
return merged;
|
package/src/commands/pr.mjs
CHANGED
|
@@ -225,8 +225,9 @@ export async function runPrListOperator({ tenant, secret, storefrontUrl = null }
|
|
|
225
225
|
if (!secret) {
|
|
226
226
|
console.error(
|
|
227
227
|
fail(
|
|
228
|
-
"
|
|
229
|
-
|
|
228
|
+
"no operator secret — view this tenant's queue from the storefront admin instead.",
|
|
229
|
+
`open https://${tenant}/admin → Publish tab to see the candidate queue and act on it. ` +
|
|
230
|
+
"Operators/CI: set PREVIEW_RECONCILE_SECRET (or GRANTS_ADMIN_SECRET / TOT_OPERATOR_SECRET), or pass --secret.",
|
|
230
231
|
),
|
|
231
232
|
);
|
|
232
233
|
return 2;
|
|
@@ -287,7 +288,7 @@ export async function run(argv, ctx) {
|
|
|
287
288
|
console.log(USAGE);
|
|
288
289
|
return 0;
|
|
289
290
|
}
|
|
290
|
-
if (!SUBCOMMANDS.includes(args.sub)) {
|
|
291
|
+
if (!SUBCOMMANDS.includes(/** @type {any} */ (args.sub))) {
|
|
291
292
|
console.error(fail(`unknown subcommand: \`tot pr ${args.sub}\``, "tot pr list | view <N> | close <N>"));
|
|
292
293
|
return 2;
|
|
293
294
|
}
|
package/src/commands/preview.mjs
CHANGED
|
@@ -45,7 +45,7 @@ export function postRunHint(code, alias) {
|
|
|
45
45
|
/**
|
|
46
46
|
* @param {string[]} argv
|
|
47
47
|
* @param {any} ctx
|
|
48
|
-
* @param {{ alias?: string|null }} [opts]
|
|
48
|
+
* @param {{ alias?: string|null }} [opts] - `alias` is the old verb the developer typed
|
|
49
49
|
* (`"submit"` / `"deploy"`) when this flow is reached as a teaching alias; null/omitted
|
|
50
50
|
* for the first-class `tot preview`.
|
|
51
51
|
*/
|