@indigoai-us/hq-cli 5.242.0 → 5.244.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.
@@ -32,6 +32,7 @@
32
32
  * actually written (we do NOT rely solely on the pre-flight scan or on the
33
33
  * tar binary's own protections).
34
34
  */
35
+ import { execFileSync } from 'child_process';
35
36
  export interface ExtractCaps {
36
37
  /** Max total uncompressed bytes across all entries. Default 256 MiB. */
37
38
  maxUncompressedBytes: number;
@@ -109,9 +110,13 @@ export declare function resolveContainedPath(targetDir: string, entryName: strin
109
110
  export declare function assertLinkTargetContained(packDir: string, entryAbs: string, linkTarget: string): void;
110
111
  /**
111
112
  * List a gzip-compressed tarball's entries via `tar -tvf` (no extraction).
112
- * argv form — never a shell string — so a hostile filename can't break out.
113
+ * argv form, never a shell string, keeps hostile filenames out of shell syntax.
114
+ * When the default-off compatibility flag is enabled, Windows drive paths use
115
+ * a basename relative to the archive directory. GNU tar cannot parse `C:\\...`
116
+ * as remote `host:path`, and Windows' bundled bsdtar accepts the same relative
117
+ * argv without GNU-only `--force-local`.
113
118
  */
114
- export declare function listTarball(tarballPath: string): TarEntry[];
119
+ export declare function listTarball(tarballPath: string, opts?: Pick<SafeExtractOptions, 'platform' | 'useRelativeWindowsDrivePath' | 'execFileSync'>): TarEntry[];
115
120
  /**
116
121
  * Pre-flight validation against the entry headers. Enforces (a) containment of
117
122
  * every entry name under `targetDir`, (b) containment of every link target
@@ -129,6 +134,10 @@ export declare function validateEntries(entries: TarEntry[], targetDir: string,
129
134
  export declare function assertExtractContained(stagingDir: string): void;
130
135
  export interface SafeExtractOptions {
131
136
  caps?: ExtractCaps;
137
+ /** Test seams for platform-specific argv construction; runtime gating uses hq-flags. */
138
+ platform?: NodeJS.Platform;
139
+ useRelativeWindowsDrivePath?: boolean;
140
+ execFileSync?: typeof execFileSync;
132
141
  /**
133
142
  * Test seam: invoked AFTER `tar` extracts into the staging dir but BEFORE
134
143
  * the post-extract sweep / atomic rename. Throwing here simulates a failure
@@ -35,6 +35,7 @@
35
35
  import { execFileSync } from 'child_process';
36
36
  import * as fs from 'fs';
37
37
  import * as path from 'path';
38
+ import { installPathCompatibilityEnabled } from '../lib/install-path-compatibility-flag.js';
38
39
  export const DEFAULT_CAPS = {
39
40
  // A content pack is docs/skills/policies/small scripts. 256 MiB is already
40
41
  // generous; anything larger is almost certainly a bomb or a mistake.
@@ -203,18 +204,34 @@ export function assertLinkTargetContained(packDir, entryAbs, linkTarget) {
203
204
  `"${linkTarget}" -> "${resolvedTarget}", outside pack dir "${path.resolve(packDir)}".`);
204
205
  }
205
206
  }
206
- // ---------------------------------------------------------------------------
207
- // Pre-flight: list entries, enforce containment + caps WITHOUT extracting
208
- // ---------------------------------------------------------------------------
207
+ function tarballArgvLocation(tarballPath, platform, useRelativeWindowsDrivePath) {
208
+ if (platform === 'win32' &&
209
+ useRelativeWindowsDrivePath &&
210
+ /^[A-Za-z]:[\\/]/.test(tarballPath)) {
211
+ return {
212
+ archiveArg: path.win32.basename(tarballPath),
213
+ cwd: path.win32.dirname(tarballPath),
214
+ };
215
+ }
216
+ return { archiveArg: tarballPath };
217
+ }
209
218
  /**
210
219
  * List a gzip-compressed tarball's entries via `tar -tvf` (no extraction).
211
- * argv form — never a shell string — so a hostile filename can't break out.
220
+ * argv form, never a shell string, keeps hostile filenames out of shell syntax.
221
+ * When the default-off compatibility flag is enabled, Windows drive paths use
222
+ * a basename relative to the archive directory. GNU tar cannot parse `C:\\...`
223
+ * as remote `host:path`, and Windows' bundled bsdtar accepts the same relative
224
+ * argv without GNU-only `--force-local`.
212
225
  */
213
- export function listTarball(tarballPath) {
214
- const out = execFileSync('tar', ['-tvf', tarballPath], {
226
+ export function listTarball(tarballPath, opts = {}) {
227
+ const platform = opts.platform ?? process.platform;
228
+ const useRelativeWindowsDrivePath = opts.useRelativeWindowsDrivePath ?? installPathCompatibilityEnabled();
229
+ const location = tarballArgvLocation(tarballPath, platform, useRelativeWindowsDrivePath);
230
+ const out = (opts.execFileSync ?? execFileSync)('tar', ['-tvf', location.archiveArg], {
215
231
  encoding: 'utf-8',
216
232
  maxBuffer: 64 * 1024 * 1024,
217
233
  stdio: ['ignore', 'pipe', 'pipe'],
234
+ ...(location.cwd ? { cwd: location.cwd } : {}),
218
235
  });
219
236
  return parseTarListing(out);
220
237
  }
@@ -311,8 +328,17 @@ export function safeExtractTarball(tarballPath, finalDir, opts = {}) {
311
328
  if (fs.existsSync(finalDir)) {
312
329
  throw new Error(`safeExtractTarball: refusing to extract over existing dir "${finalDir}".`);
313
330
  }
331
+ // Resolve once so listing and extraction use the same archive location.
332
+ const platform = opts.platform ?? process.platform;
333
+ const useRelativeWindowsDrivePath = opts.useRelativeWindowsDrivePath ?? installPathCompatibilityEnabled();
334
+ const exec = opts.execFileSync ?? execFileSync;
335
+ const location = tarballArgvLocation(tarballPath, platform, useRelativeWindowsDrivePath);
314
336
  // 1. Pre-flight against headers (no bytes written yet).
315
- const entries = listTarball(tarballPath);
337
+ const entries = listTarball(tarballPath, {
338
+ platform,
339
+ useRelativeWindowsDrivePath,
340
+ execFileSync: exec,
341
+ });
316
342
  validateEntries(entries, finalDir, caps);
317
343
  // 2. Sibling staging dir on the same filesystem so the rename is atomic.
318
344
  const parent = path.dirname(path.resolve(finalDir));
@@ -322,7 +348,10 @@ export function safeExtractTarball(tarballPath, finalDir, opts = {}) {
322
348
  // `--no-same-owner` avoids chown surprises when run as root; argv form, no
323
349
  // shell. We deliberately re-extract here (not the pre-flight listing) and
324
350
  // re-validate after, rather than trusting tar to honor any single flag.
325
- execFileSync('tar', ['-xzf', tarballPath, '-C', stagingDir, '--no-same-owner'], { stdio: 'inherit' });
351
+ exec('tar', ['-xzf', location.archiveArg, '-C', stagingDir, '--no-same-owner'], {
352
+ stdio: 'inherit',
353
+ ...(location.cwd ? { cwd: location.cwd } : {}),
354
+ });
326
355
  // 3. Independent backstop sweep over what actually landed.
327
356
  assertExtractContained(stagingDir);
328
357
  // 4. Failure-injection seam (tests).
@@ -0,0 +1,8 @@
1
+ import type { RememberedLoginProvider } from "../utils/login-provider.js";
2
+ export declare const LOGIN_PROVIDER_STATE_FILE_NAME = "auth-login-provider.json";
3
+ export declare function getLastLoginProviderStatePath(): string;
4
+ /** Read only the provider preference; malformed or unknown state is ignored. */
5
+ export declare function loadLastLoginProvider(): RememberedLoginProvider | undefined;
6
+ /** Store only the provider name, clearing stale state when the choice is unknown. */
7
+ export declare function storeLastLoginProvider(provider: RememberedLoginProvider | undefined): void;
8
+ //# sourceMappingURL=auth-login-provider-state.d.ts.map
@@ -0,0 +1,63 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs";
3
+ import path from "node:path";
4
+ import { personTokenCacheFile } from "../utils/cognito-session.js";
5
+ export const LOGIN_PROVIDER_STATE_FILE_NAME = "auth-login-provider.json";
6
+ export function getLastLoginProviderStatePath() {
7
+ return path.join(path.dirname(personTokenCacheFile()), LOGIN_PROVIDER_STATE_FILE_NAME);
8
+ }
9
+ function isRememberedProvider(value) {
10
+ return value === "google" || value === "microsoft";
11
+ }
12
+ /** Read only the provider preference; malformed or unknown state is ignored. */
13
+ export function loadLastLoginProvider() {
14
+ let raw;
15
+ try {
16
+ raw = readFileSync(getLastLoginProviderStatePath(), "utf8");
17
+ }
18
+ catch (error) {
19
+ if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
20
+ return undefined;
21
+ }
22
+ throw error;
23
+ }
24
+ let value;
25
+ try {
26
+ value = JSON.parse(raw);
27
+ }
28
+ catch {
29
+ return undefined;
30
+ }
31
+ if (!value || typeof value !== "object" || Array.isArray(value))
32
+ return undefined;
33
+ const state = value;
34
+ const keys = Object.keys(state).sort();
35
+ if (keys.length !== 2
36
+ || keys[0] !== "lastProvider"
37
+ || keys[1] !== "schemaVersion"
38
+ || state.schemaVersion !== 1
39
+ || !isRememberedProvider(state.lastProvider)) {
40
+ return undefined;
41
+ }
42
+ return state.lastProvider;
43
+ }
44
+ /** Store only the provider name, clearing stale state when the choice is unknown. */
45
+ export function storeLastLoginProvider(provider) {
46
+ const filePath = getLastLoginProviderStatePath();
47
+ if (!provider) {
48
+ rmSync(filePath, { force: true });
49
+ return;
50
+ }
51
+ const directory = path.dirname(filePath);
52
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
53
+ const temporaryPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
54
+ try {
55
+ const state = { schemaVersion: 1, lastProvider: provider };
56
+ writeFileSync(temporaryPath, JSON.stringify(state), { mode: 0o600, flag: "wx" });
57
+ renameSync(temporaryPath, filePath);
58
+ }
59
+ finally {
60
+ rmSync(temporaryPath, { force: true });
61
+ }
62
+ }
63
+ //# sourceMappingURL=auth-login-provider-state.js.map
@@ -27,9 +27,6 @@ export declare const PROGRESS_COALESCE_MS = 2000;
27
27
  export declare const PROGRESS_MIN_GAP_MS = 4000;
28
28
  export declare const WORKING_NOTICE_MS = 60000;
29
29
  export declare const WORKING_NOTICE_TEXT = "Working on it \u2014 I'll reply here when it's done.";
30
- /** The setup bot's heads-up, sent once when a turn runs past SETUP_WORKING_NOTICE_MS. */
31
- export declare const SETUP_WORKING_NOTICE_MS = 30000;
32
- export declare const SETUP_WORKING_NOTICE_TEXT = "This one takes a minute. I'll reply here as soon as it's done.";
33
30
  export interface ProgressPosterOptions {
34
31
  /** Post one message; resolve true when it was delivered. */
35
32
  send: (body: string) => Promise<boolean>;
@@ -27,9 +27,6 @@ export const PROGRESS_COALESCE_MS = 2_000;
27
27
  export const PROGRESS_MIN_GAP_MS = 4_000;
28
28
  export const WORKING_NOTICE_MS = 60_000;
29
29
  export const WORKING_NOTICE_TEXT = "Working on it — I'll reply here when it's done.";
30
- /** The setup bot's heads-up, sent once when a turn runs past SETUP_WORKING_NOTICE_MS. */
31
- export const SETUP_WORKING_NOTICE_MS = 30_000;
32
- export const SETUP_WORKING_NOTICE_TEXT = "This one takes a minute. I'll reply here as soon as it's done.";
33
30
  /** Whitespace-insensitive form used to recognise repeated text. */
34
31
  export function normalizeForCompare(text) {
35
32
  return text.replace(/\s+/g, "");
@@ -57,7 +57,7 @@ import { ensureBotSessionMeta } from "./company-bind.js";
57
57
  import { readBotCredsIdentity } from "./creds.js";
58
58
  import { isProcessed, markProcessed, readInboxState } from "./inbox-state.js";
59
59
  import { clearInflight, patchInflight, readInflight, readInflightTurns, writeInflight } from "./inflight.js";
60
- import { containsNoReplySentinel, ProgressPoster, SETUP_WORKING_NOTICE_MS, SETUP_WORKING_NOTICE_TEXT, } from "./progress.js";
60
+ import { containsNoReplySentinel, ProgressPoster, } from "./progress.js";
61
61
  import { splitBody } from "./split.js";
62
62
  import { createBotLogger } from "./log.js";
63
63
  import { buildSystemPrompt, introDmText, readPromptSources } from "./prompt.js";
@@ -737,12 +737,11 @@ export async function runBot(deps) {
737
737
  };
738
738
  const poster = new ProgressPoster({
739
739
  // The setup bot talks to someone new to HQ: its notes to itself never
740
- // reach them, and a long step gets a heads-up instead of silence.
741
- ...(isSetupBot
742
- ? { dropPreToolMessages: true, workingNoticeMs: SETUP_WORKING_NOTICE_MS, workingNoticeText: SETUP_WORKING_NOTICE_TEXT }
743
- : {}),
740
+ // reach them, and it posts no "still working" notice either — the
741
+ // desktop app shows that the bot is working.
742
+ ...(isSetupBot ? { dropPreToolMessages: true } : {}),
744
743
  ...deps.progress,
745
- ...(args.workingNotice === false || silenceable ? { workingNoticeMs: 0 } : {}),
744
+ ...(args.workingNotice === false || silenceable || isSetupBot ? { workingNoticeMs: 0 } : {}),
746
745
  ...(silenceable ? { buffer: true } : {}),
747
746
  send: makeSendFn(args.send),
748
747
  ...(args.sendFinal ? { sendFinal: makeSendFn(args.sendFinal) } : {}),
@@ -1458,7 +1457,6 @@ export async function runBot(deps) {
1458
1457
  tReceived,
1459
1458
  send,
1460
1459
  sendFinal,
1461
- // The setup bot's own notice (SETUP_WORKING_NOTICE_TEXT) is set where the poster is built.
1462
1460
  silenceable: target.kind === "room" && !target.mentioned,
1463
1461
  ...(readDirs.length ? { readDirs } : {}),
1464
1462
  ...(target.kind === "room"
@@ -0,0 +1,9 @@
1
+ export declare const INSTALL_PATH_COMPATIBILITY_FLAG = "cli.install-path-compatibility";
2
+ export declare const INSTALL_PATH_COMPATIBILITY_FALLBACK = false;
3
+ /**
4
+ * Read the default-off rollout flag from the process snapshot. Missing,
5
+ * unreadable, or not-yet-loaded registry state keeps the compatibility fixes
6
+ * disabled until the operator enables a canary.
7
+ */
8
+ export declare function installPathCompatibilityEnabled(): boolean;
9
+ //# sourceMappingURL=install-path-compatibility-flag.d.ts.map
@@ -0,0 +1,12 @@
1
+ import { resolveProcessFlagGate } from "./flag-registry.js";
2
+ export const INSTALL_PATH_COMPATIBILITY_FLAG = "cli.install-path-compatibility";
3
+ export const INSTALL_PATH_COMPATIBILITY_FALLBACK = false;
4
+ /**
5
+ * Read the default-off rollout flag from the process snapshot. Missing,
6
+ * unreadable, or not-yet-loaded registry state keeps the compatibility fixes
7
+ * disabled until the operator enables a canary.
8
+ */
9
+ export function installPathCompatibilityEnabled() {
10
+ return resolveProcessFlagGate(INSTALL_PATH_COMPATIBILITY_FLAG, {}, () => INSTALL_PATH_COMPATIBILITY_FALLBACK);
11
+ }
12
+ //# sourceMappingURL=install-path-compatibility-flag.js.map
package/dist/main.js CHANGED
@@ -180,12 +180,15 @@ program.hook("preAction", async () => {
180
180
  export async function runCli() {
181
181
  const foregroundNetworkBudget = new ForegroundNetworkBudget();
182
182
  activeForegroundNetworkBudget = foregroundNetworkBudget;
183
- // Begin one registry refresh without awaiting it. Every command gate reads
184
- // only the client's held snapshot and falls back locally, so an offline or
185
- // slow registry cannot delay command parsing or alter a command failure.
186
- runGlobalNetworkLifecycle(process.argv, () => {
187
- void kickFlagRegistryReadiness();
188
- });
183
+ // Login must be usable before any identity or company is available, so do
184
+ // not make a pre-auth feature-flag request on this path. Other commands
185
+ // retain the shared asynchronous registry refresh.
186
+ const isAuthLogin = process.argv[2] === "auth" && process.argv[3] === "login";
187
+ if (!isAuthLogin) {
188
+ runGlobalNetworkLifecycle(process.argv, () => {
189
+ void kickFlagRegistryReadiness();
190
+ });
191
+ }
189
192
  // Set when a self-update re-exec'd this command on a newer CLI: the child
190
193
  // already did the work, so this process only has to carry its exit status
191
194
  // out (after the finally block's telemetry, hence not process.exit here).
@@ -29,6 +29,7 @@ import { loadCachedTokens, isExpiring, refreshTokens, saveCachedTokens, browserL
29
29
  import { CLI_NAME, CLI_VERSION } from "../cli-version.js";
30
30
  import { daemonDir } from "../lib/mesh/live/daemon/paths.js";
31
31
  import { workMeshRoot } from "../lib/mesh/live/paths.js";
32
+ import { acquireLockFileExclusively } from "./exclusive-lock.js";
32
33
  // Re-export the cached-session primitives for callers that must deliberately
33
34
  // avoid the refresh/login behavior in the higher-level helpers below.
34
35
  export { isExpiring, isMachineIdentity, loadCachedTokens };
@@ -557,7 +558,7 @@ async function withAbortablePersonRefreshLock(signal, fn) {
557
558
  for (;;) {
558
559
  throwIfAborted(signal);
559
560
  try {
560
- fs.linkSync(candidate, lockFile);
561
+ acquireLockFileExclusively(candidate, lockFile, stateDir);
561
562
  held = true;
562
563
  break;
563
564
  }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Acquiring a lock file on a filesystem that may not do hard links.
3
+ *
4
+ * The refresh lock is a classic POSIX `link()` lock: write a candidate file,
5
+ * hard-link it into place, and let EEXIST mean somebody else holds it. That
6
+ * is atomic on a local disk and on NFS, and it is simply absent on CIFS/SMB
7
+ * — Azure Files returns ENOTSUP for `link()` on every call, so a bot whose
8
+ * agent home lives on such a share could never mint a token at all. Its
9
+ * probe failed every line with:
10
+ *
11
+ * ENOTSUP: operation not supported on socket, link
12
+ * '.../cognito-tokens.json.lock.candidate.48560...' -> '.../lock'
13
+ *
14
+ * An exclusive create (`open(..., O_EXCL)`) gives the same guarantee — the
15
+ * server refuses the second creator — and works on those shares. So the link
16
+ * is tried first, and a filesystem that says it cannot do links is remembered
17
+ * and switched to the exclusive-create path for the rest of the process.
18
+ *
19
+ * Both paths fail with EEXIST when the lock is held, so contention, stale-
20
+ * owner reclamation and release are unchanged for every caller.
21
+ */
22
+ /** Test seam: forget what we learned about which filesystems do links. */
23
+ export declare function resetLinkSupportCache(): void;
24
+ /**
25
+ * Create `lockFile` exclusively, or throw EEXIST if it already exists.
26
+ *
27
+ * `candidate` is an already-written file holding the owner's pid; on a
28
+ * link-capable filesystem it becomes the lock, and otherwise its contents
29
+ * are written directly. Either way the lock ends up holding the same bytes.
30
+ */
31
+ export declare function acquireLockFileExclusively(candidate: string, lockFile: string, dirKey: string): void;
32
+ //# sourceMappingURL=exclusive-lock.d.ts.map
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Acquiring a lock file on a filesystem that may not do hard links.
3
+ *
4
+ * The refresh lock is a classic POSIX `link()` lock: write a candidate file,
5
+ * hard-link it into place, and let EEXIST mean somebody else holds it. That
6
+ * is atomic on a local disk and on NFS, and it is simply absent on CIFS/SMB
7
+ * — Azure Files returns ENOTSUP for `link()` on every call, so a bot whose
8
+ * agent home lives on such a share could never mint a token at all. Its
9
+ * probe failed every line with:
10
+ *
11
+ * ENOTSUP: operation not supported on socket, link
12
+ * '.../cognito-tokens.json.lock.candidate.48560...' -> '.../lock'
13
+ *
14
+ * An exclusive create (`open(..., O_EXCL)`) gives the same guarantee — the
15
+ * server refuses the second creator — and works on those shares. So the link
16
+ * is tried first, and a filesystem that says it cannot do links is remembered
17
+ * and switched to the exclusive-create path for the rest of the process.
18
+ *
19
+ * Both paths fail with EEXIST when the lock is held, so contention, stale-
20
+ * owner reclamation and release are unchanged for every caller.
21
+ */
22
+ import * as fs from "fs";
23
+ /**
24
+ * `link()` errnos that mean "this filesystem does not implement links"
25
+ * rather than "the link failed". EEXIST is deliberately NOT here: that is
26
+ * ordinary contention and the caller's own retry loop owns it.
27
+ */
28
+ const LINK_UNSUPPORTED_CODES = new Set([
29
+ "ENOTSUP",
30
+ "EOPNOTSUPP",
31
+ "EPERM",
32
+ "EXDEV",
33
+ "EACCES",
34
+ ]);
35
+ /**
36
+ * Per-process, per-directory memory of a filesystem that cannot hard-link,
37
+ * so the cost is one failed call rather than one per acquisition.
38
+ */
39
+ const linkUnsupportedDirs = new Set();
40
+ /** Test seam: forget what we learned about which filesystems do links. */
41
+ export function resetLinkSupportCache() {
42
+ linkUnsupportedDirs.clear();
43
+ }
44
+ /**
45
+ * Create `lockFile` exclusively, or throw EEXIST if it already exists.
46
+ *
47
+ * `candidate` is an already-written file holding the owner's pid; on a
48
+ * link-capable filesystem it becomes the lock, and otherwise its contents
49
+ * are written directly. Either way the lock ends up holding the same bytes.
50
+ */
51
+ export function acquireLockFileExclusively(candidate, lockFile, dirKey) {
52
+ if (!linkUnsupportedDirs.has(dirKey)) {
53
+ try {
54
+ fs.linkSync(candidate, lockFile);
55
+ return;
56
+ }
57
+ catch (error) {
58
+ const code = error.code ?? "";
59
+ // Contention is the caller's business, and an unrecognised failure is
60
+ // a real fault: neither one means "try the other strategy".
61
+ if (code === "EEXIST")
62
+ throw error;
63
+ if (!LINK_UNSUPPORTED_CODES.has(code))
64
+ throw error;
65
+ linkUnsupportedDirs.add(dirKey);
66
+ }
67
+ }
68
+ // O_EXCL: the create fails with EEXIST if anyone got there first, which is
69
+ // exactly the signal the link path produced.
70
+ fs.writeFileSync(lockFile, fs.readFileSync(candidate), {
71
+ flag: "wx",
72
+ mode: 0o600,
73
+ });
74
+ }
75
+ //# sourceMappingURL=exclusive-lock.js.map
@@ -5,7 +5,14 @@ declare const PROVIDER_TO_COGNITO_IDP: {
5
5
  readonly picker: undefined;
6
6
  };
7
7
  export type LoginProvider = keyof typeof PROVIDER_TO_COGNITO_IDP;
8
+ export type RememberedLoginProvider = Exclude<LoginProvider, "picker">;
9
+ export interface LoginProviderSelection {
10
+ config: CognitoAuthConfig;
11
+ providerToRemember?: RememberedLoginProvider;
12
+ }
8
13
  export declare function parseLoginProvider(value: string | undefined): LoginProvider | undefined;
9
14
  export declare function cognitoConfigForLoginProvider(value: string | undefined): CognitoAuthConfig;
15
+ /** Resolve CLI, environment, remembered, then first-login choice-page precedence. */
16
+ export declare function resolveLoginProviderSelection(explicitProvider: string | undefined, env: NodeJS.ProcessEnv, rememberedProvider?: RememberedLoginProvider): LoginProviderSelection;
10
17
  export {};
11
18
  //# sourceMappingURL=login-provider.d.ts.map
@@ -22,4 +22,38 @@ export function cognitoConfigForLoginProvider(value) {
22
22
  identityProvider: PROVIDER_TO_COGNITO_IDP[provider],
23
23
  };
24
24
  }
25
+ function rememberedProviderForCognitoProvider(value) {
26
+ const normalized = value?.trim().toLowerCase();
27
+ if (normalized === "google")
28
+ return "google";
29
+ if (normalized === "microsoft" || normalized === "microsoftpersonal")
30
+ return "microsoft";
31
+ return undefined;
32
+ }
33
+ /** Resolve CLI, environment, remembered, then first-login choice-page precedence. */
34
+ export function resolveLoginProviderSelection(explicitProvider, env, rememberedProvider) {
35
+ if (explicitProvider !== undefined) {
36
+ const provider = parseLoginProvider(explicitProvider);
37
+ return {
38
+ config: cognitoConfigForLoginProvider(explicitProvider),
39
+ ...(provider && provider !== "picker" ? { providerToRemember: provider } : {}),
40
+ };
41
+ }
42
+ if (env.HQ_COGNITO_IDENTITY_PROVIDER !== undefined) {
43
+ const identityProvider = env.HQ_COGNITO_IDENTITY_PROVIDER.trim() || undefined;
44
+ return {
45
+ config: { ...DEFAULT_COGNITO, identityProvider },
46
+ ...(rememberedProviderForCognitoProvider(identityProvider)
47
+ ? { providerToRemember: rememberedProviderForCognitoProvider(identityProvider) }
48
+ : {}),
49
+ };
50
+ }
51
+ if (rememberedProvider) {
52
+ return {
53
+ config: cognitoConfigForLoginProvider(rememberedProvider),
54
+ providerToRemember: rememberedProvider,
55
+ };
56
+ }
57
+ return { config: cognitoConfigForLoginProvider("picker") };
58
+ }
25
59
  //# sourceMappingURL=login-provider.js.map
@@ -52,8 +52,8 @@ export declare function npmPrefixFromPackageDir(pkgDir: string): string | null;
52
52
  * on PATH:
53
53
  *
54
54
  * $PNPM_HOME/hq <- shim on PATH
55
- * $PNPM_HOME/global/5/node_modules/@indigoai-us/hq-cli <- symlink
56
- * $PNPM_HOME/global/5/.pnpm/@indigoai-us+hq-cli@5.61.0/node_modules/…
55
+ * pnpm through v10: $PNPM_HOME/global/5/node_modules/@indigoai-us/hq-cli
56
+ * pnpm v11+: $PNPM_HOME/global/v11/<hash>/node_modules/@indigoai-us/hq-cli
57
57
  *
58
58
  * Both the symlinked and the resolved (`.pnpm`) form are recognised, because
59
59
  * whether `import.meta.url` reports the link or its target depends on how node
@@ -67,7 +67,7 @@ export declare function npmPrefixFromPackageDir(pkgDir: string): string | null;
67
67
  * i.e. the gate would re-fire and re-install globally on every subsequent run.
68
68
  * Those layouts fall through to the ordinary npm-prefix/manual handling.
69
69
  */
70
- export declare function isPnpmManagedPackageDir(pkgDir: string): boolean;
70
+ export declare function isPnpmManagedPackageDir(pkgDir: string, acceptVersionPrefixedGlobalDir?: boolean): boolean;
71
71
  /** Whether the running package lives inside Bun's global install tree. */
72
72
  export declare function isBunManagedPackageDir(pkgDir: string): boolean;
73
73
  /**
@@ -28,6 +28,7 @@
28
28
  * to silence both check + gate).
29
29
  */
30
30
  import { spawnSync } from "node:child_process";
31
+ import { installPathCompatibilityEnabled } from "../lib/install-path-compatibility-flag.js";
31
32
  import { buildSpawnPlan, quoteForWindowsShell } from "./windows-spawn.js";
32
33
  import { accessSync, closeSync, constants as fsConstants, existsSync, mkdtempSync, openSync, readdirSync, readFileSync, rmSync, } from "node:fs";
33
34
  import os from "node:os";
@@ -93,8 +94,8 @@ function pnpmHomeGlobalRoot() {
93
94
  * on PATH:
94
95
  *
95
96
  * $PNPM_HOME/hq <- shim on PATH
96
- * $PNPM_HOME/global/5/node_modules/@indigoai-us/hq-cli <- symlink
97
- * $PNPM_HOME/global/5/.pnpm/@indigoai-us+hq-cli@5.61.0/node_modules/…
97
+ * pnpm through v10: $PNPM_HOME/global/5/node_modules/@indigoai-us/hq-cli
98
+ * pnpm v11+: $PNPM_HOME/global/v11/<hash>/node_modules/@indigoai-us/hq-cli
98
99
  *
99
100
  * Both the symlinked and the resolved (`.pnpm`) form are recognised, because
100
101
  * whether `import.meta.url` reports the link or its target depends on how node
@@ -108,18 +109,26 @@ function pnpmHomeGlobalRoot() {
108
109
  * i.e. the gate would re-fire and re-install globally on every subsequent run.
109
110
  * Those layouts fall through to the ordinary npm-prefix/manual handling.
110
111
  */
111
- export function isPnpmManagedPackageDir(pkgDir) {
112
+ export function isPnpmManagedPackageDir(pkgDir, acceptVersionPrefixedGlobalDir = false) {
112
113
  const normalized = pkgDir.replace(/\\/g, "/").replace(/\/+$/, "");
113
114
  const segments = normalized.split("/").filter(Boolean);
114
- // `global/<digits>` — pnpm's global root, in either the symlinked or the
115
- // resolved (`.pnpm`) form, and independent of where PNPM_HOME points.
115
+ // pnpm through v10 uses `global/<digits>`; pnpm v11+ uses
116
+ // `global/v<major>`. Keep the newer form behind the default-off rollout
117
+ // flag, and require a documented version segment so `global/next` or a
118
+ // `v11` directory elsewhere cannot be mistaken for a global install.
116
119
  for (let i = 0; i < segments.length - 1; i += 1) {
117
- if (segments[i] === "global" && /^\d+$/.test(segments[i + 1]))
120
+ if (segments[i] !== "global")
121
+ continue;
122
+ const globalDirVersion = segments[i + 1];
123
+ if (/^\d+$/.test(globalDirVersion))
118
124
  return true;
125
+ if (acceptVersionPrefixedGlobalDir && /^v\d+$/.test(globalDirVersion)) {
126
+ return true;
127
+ }
119
128
  }
120
- // Belt-and-braces for a future pnpm layout whose store dir is not numeric:
121
- // anything under `$PNPM_HOME/global` is global by construction. Still keyed
122
- // on the `global` segment so `$PNPM_HOME/store/**` (dlx caches) stays out.
129
+ // PNPM_HOME/global is pnpm's configured global root by construction, so
130
+ // keep this fallback in both flag states. The flag above gates only the new
131
+ // documented global/v<major> segment match outside that configured root.
123
132
  const globalRoot = pnpmHomeGlobalRoot();
124
133
  if (globalRoot) {
125
134
  if (normalized === globalRoot || normalized.startsWith(`${globalRoot}/`)) {
@@ -246,7 +255,7 @@ export function resolveRunningInstall() {
246
255
  const packageRoot = findRunningPackageRoot();
247
256
  if (!packageRoot)
248
257
  return { manager: "npm", prefix: null, packageRoot: null };
249
- if (isPnpmManagedPackageDir(packageRoot)) {
258
+ if (isPnpmManagedPackageDir(packageRoot, installPathCompatibilityEnabled())) {
250
259
  return { manager: "pnpm", prefix: null, packageRoot };
251
260
  }
252
261
  if (isBunManagedPackageDir(packageRoot)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.242.0",
3
+ "version": "5.244.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -34,6 +34,7 @@
34
34
  "test:db": "vitest run src/lib/db test/commands/db.test.ts test/commands/db-tenant-isolation.test.ts",
35
35
  "test:e2e": "vitest run --config vitest.e2e.config.ts",
36
36
  "coverage": "vitest run --coverage --coverage.reporter=text-summary --coverage.reporter=json-summary",
37
+ "check:lanes-module-cycles": "node scripts/check-lanes-module-cycles.mjs",
37
38
  "vitest": "vitest",
38
39
  "clean": "rm -rf dist",
39
40
  "postinstall": "node scripts/mesh-daemon-if-stale.mjs || true",