Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/guides/init-project.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ Notes:
`hack doctor --browser-url`; DNS setup alone does not validate HTTPS or OAuth.
Pass `--dev-host` for a per-project hostname; it takes precedence over the
global default. Existing project hosts and Compose routes are not migrated.
- On macOS, Safari opens `.hack.local` names directly. Chrome, Firefox and other
browsers need the macOS Local Network permission once. See
[macOS browser connectivity](macos-browser-network.md) for the reason and for
alternatives that avoid `.local`.
- `hack init` also scaffolds `.hack/hack.env.default.yaml`. See `docs/env.md`.
- `hack up` starts the stack on an isolated network.
- `hack open` resolves the routed URL via the global proxy and prefers the OAuth alias when
Expand Down Expand Up @@ -117,6 +121,8 @@ Use Doctor to check for missing resolver configuration before starting a new pro
review the proposed global setup changes rather than removing existing resolver files.
`.local` has special multicast DNS semantics ([RFC 6762, section 3](https://www.rfc-editor.org/rfc/rfc6762.html#section-3)),
so generated configuration alone does not establish host or browser reachability.
On macOS, browsers other than Safari need a one-time Local Network grant for
`.local` names; see [macOS browser connectivity](macos-browser-network.md).
Live DNS, route and TLS qualification remains a prerelease acceptance gate.

## What discovery checks (and what it can't)
Expand Down
39 changes: 39 additions & 0 deletions docs/guides/macos-browser-network.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,45 @@ Apple exempts command-line tools launched from Terminal or SSH. See Apple's
[Local Network privacy explanation](https://developer.apple.com/documentation/technotes/tn3179-understanding-local-network-privacy)
and [permission settings guide](https://support.apple.com/guide/mac-help/mchla4f49138/mac).

## `.local` names need a one-time grant outside Safari

New projects use `<project>.hack.local` by default. macOS applies Local Network
privacy by name as well as by address: Apple's TN3179 treats a `.local` lookup
as local network activity because `.local` is the multicast DNS (Bonjour)
namespace. A browser that opens `https://my-app.hack.local` therefore needs
Local Network access, even though Hack's resolver answers with a loopback
address.

- **Safari** opens `.local` names without this permission.
- **Chrome, Firefox and other browsers** need it once per app. macOS asks the
first time; if you declined, enable it in **System Settings → Privacy &
Security → Local Network**, then fully quit and reopen the browser.

On macOS, Hack prints one hint line on stderr when a command opens or prints a
`.local` browser URL (`hack open`, `hack branch open`, `hack status` and
`hack projects`, and Doctor's browser check). The hint is shown at most once per
command and never appears inside `--json` output. Hack does not read, request or
change this permission for you.

If you prefer not to grant the permission, use a name outside `.local`:

- **OAuth alias:** enable `oauth.enabled` (or `hack init --oauth`) and open the
project's `<project>.hack.gy` alias. `hack open` prefers it automatically;
`hack open --prefer alias` requires it.
- **Custom suffix:** choose a dedicated suffix under `hack.gy`, for example
`dev.hack.gy`. With the native candidate, run
`hack global dns preview --domain dev.hack.gy` and then
`hack global dns activate --domain dev.hack.gy`
(see [native candidate DNS](native-candidate.md)). With the Docker-backed
stack, set `hack config set --global default_domain <suffix>` and run
`hack global install` (see [Initialize a project](init-project.md)).
Existing project hosts are not migrated automatically.

Avoiding `.local` avoids only the name-based rule. Address-based checks still
apply, so verify the exact origin in the browser as described below.

## Diagnose a browser failure

Run `hack doctor` first. DNS, resolver, Caddy, project services and certificate trust
have separate checks. The browser check remains unverified until you report an
observation; Doctor does not read the browser's permissions or its private data.
Expand Down
3 changes: 3 additions & 0 deletions docs/guides/native-candidate.md
Original file line number Diff line number Diff line change
Expand Up @@ -1301,6 +1301,9 @@ not delete these files or receipts blindly. The built-in `hack`,
`hack.local`, and `hack.gy` roots cannot be claimed wholesale by this command;
a dedicated `v5.hack.gy` subtree is supported when its v4 parent is verified.
Existing project hosts and reviewed routes are not migrated.
A suffix outside `.local`, such as `v5.hack.gy`, also avoids the macOS
Local Network grant that browsers other than Safari need for `.local` names;
see [macOS browser connectivity](macos-browser-network.md).

DNS activation alone does not prove that an app route, certificate, port 443,
or OAuth redirect is ready. Use `hack doctor --browser-url` for the exact app
Expand Down
2 changes: 2 additions & 0 deletions src/commands/branch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
import { optOpenPreference, optPath, optProject } from "../cli/options.ts";
import { DEFAULT_PROJECT_TLD, HACK_PROJECT_DIR_PRIMARY } from "../constants.ts";
import { readBranchesFile, writeBranchesFile } from "../lib/branches.ts";
import { noteBrowserTargetsForLocalNetworkHint } from "../lib/local-network-hint.ts";
import {
parseOpenHostPreference,
resolvePreferredOpenHost,
Expand Down Expand Up @@ -325,6 +326,7 @@ const handleBranchOpen: CommandHandlerFor<typeof branchOpenSpec> = async ({
const url = `https://${resolvedHost}`;

logger.step({ message: `Opening ${url}` });
noteBrowserTargetsForLocalNetworkHint([url]);
return await openUrl(url);
};

Expand Down
85 changes: 73 additions & 12 deletions src/commands/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ import {
isExplicitlyNonInteractive,
} from "../lib/interactivity.ts";
import { findHackLocalCaCertPath } from "../lib/local-ca.ts";
import { noteBrowserTargetsForLocalNetworkHint } from "../lib/local-network-hint.ts";
import {
describeSupersededRoots,
inspectSupersededMacRoots,
Expand Down Expand Up @@ -915,16 +916,23 @@ const handleDoctor: CommandHandlerFor<typeof doctorSpec> = async ({

const hasError = results.some((r) => r.status === "error");

if (json) {
return finishDoctorJson({
results,
hasError,
});
const reported = await reportDoctorResults({
results,
hasError,
json,
browserHintTargets: composeDoctorBrowserHintTargets({
url: browser.url,
defaultDomain,
}),
renderHuman: async () => {
await renderDoctorSummary(results);
emitSlowChecksNote(results);
await renderMacNote(results);
},
});
if (reported !== null) {
return reported;
}

await renderDoctorSummary(results);
emitSlowChecksNote(results);
await renderMacNote(results);
if (!args.options.fix) {
await renderRecoveryGuidance(results);
}
Expand All @@ -945,6 +953,53 @@ const handleDoctor: CommandHandlerFor<typeof doctorSpec> = async ({
return 0;
};

/**
* Compose doctor's browser check concerns the reported origin, else the
* default domain.
*/
export function composeDoctorBrowserHintTargets(opts: {
readonly url: string | null;
readonly defaultDomain: string;
}): readonly string[] {
return [opts.url ?? opts.defaultDomain];
}

/** Native doctor names a browser target only through `--browser-url`. */
export function nativeDoctorBrowserHintTargets(
url: string | null
): readonly string[] {
return url ? [url] : [];
}

/**
* Writes the doctor report, then the macOS Local Network hint for the
* browser targets. JSON mode writes the envelope to stdout and returns its
* exit code; the hint goes to stderr afterwards, so the payload is unchanged.
* Human mode runs `renderHuman` and returns null so the caller continues.
* `noteHint` defaults to the process-wide hint; tests inject an emitter.
*/
export async function reportDoctorResults(opts: {
readonly results: readonly TimedCheckResult[];
readonly hasError: boolean;
readonly json: boolean;
readonly browserHintTargets: readonly string[];
readonly renderHuman: () => Promise<void>;
readonly noteHint?: (targets: readonly string[]) => boolean;
}): Promise<number | null> {
const noteHint = opts.noteHint ?? noteBrowserTargetsForLocalNetworkHint;
if (opts.json) {
const code = finishDoctorJson({
results: opts.results,
hasError: opts.hasError,
});
noteHint(opts.browserHintTargets);
return code;
}
await opts.renderHuman();
noteHint(opts.browserHintTargets);
return null;
}

function resolveDoctorStartDir(pathOption: string | undefined): string {
return resolve(process.cwd(), pathOption ?? ".");
}
Expand Down Expand Up @@ -1086,10 +1141,16 @@ async function handleNativeDoctor(opts: {
}
progress.stop(`Native doctor checks complete (${results.length} checks)`);
const hasError = results.some((result) => result.status === "error");
if (opts.json) {
return finishDoctorJson({ results, hasError });
const reported = await reportDoctorResults({
results,
hasError,
json: opts.json,
browserHintTargets: nativeDoctorBrowserHintTargets(opts.browser.url),
renderHuman: () => renderDoctorSummary(results),
});
if (reported !== null) {
return reported;
}
await renderDoctorSummary(results);
if (!opts.browser.url) {
note(
"Pass --browser-url https://your-app.hack.local --browser-result works|fails|permission-denied to check an exact app route and record a separate browser observation.",
Expand Down
3 changes: 3 additions & 0 deletions src/commands/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,7 @@ import {
appendHackHostTrustEnvironment,
findHackHostTrustBundlePath,
} from "../lib/local-ca.ts";
import { noteBrowserTargetsForLocalNetworkHint } from "../lib/local-network-hint.ts";
import {
buildLogSelector,
resolveShouldTryLoki,
Expand Down Expand Up @@ -8431,9 +8432,11 @@ async function emitOpenOrigin(opts: {
}): Promise<number> {
if (opts.json) {
process.stdout.write(`${JSON.stringify({ url: opts.url }, null, 2)}\n`);
noteBrowserTargetsForLocalNetworkHint([opts.url]);
return 0;
}
logger.step({ message: `Opening ${opts.url}` });
noteBrowserTargetsForLocalNetworkHint([opts.url]);
return await openUrl(opts.url);
}

Expand Down
4 changes: 4 additions & 0 deletions src/commands/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
} from "../lib/caddy-hosts.ts";
import { emitCliResult, okResult } from "../lib/cli-result.ts";
import { confirmSafe } from "../lib/interactivity.ts";
import { noteBrowserTargetsForLocalNetworkHint } from "../lib/local-network-hint.ts";
import {
createOperationTimings,
type OperationTimings,
Expand Down Expand Up @@ -590,6 +591,9 @@ async function runProjects(opts: {
});
}
}
noteBrowserTargetsForLocalNetworkHint(
views.flatMap((p) => (p.devHost ? [p.devHost] : []))
);

return 0;
}
Expand Down
113 changes: 113 additions & 0 deletions src/lib/local-network-hint.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
/**
* One-line macOS Local Network hint for browser-facing `.local` URLs.
*
* macOS decides Local Network access by name: a browser that resolves a
* `.local` hostname needs the per-app Local Network permission even when
* Hack's resolver answers with loopback (Apple TN3179). Safari is exempt;
* other browsers need a one-time grant.
*
* Contract:
* - The hint text is a closed constant: no hostnames, paths or other
* interpolated data.
* - It is written to stderr only, never to stdout and never into a `--json`
* payload, so machine-readable output stays unchanged.
* - It appears only on macOS, only for hosts ending in `.local`, and at most
* once per emitter. The process-wide emitter makes that once per command.
* - It never reads or changes the permission itself.
*/

export const LOCAL_NETWORK_HINT_MESSAGE =
"Hint: on macOS, browsers other than Safari need a one-time Local Network permission to open .local names (System Settings > Privacy & Security > Local Network). See docs/guides/macos-browser-network.md.";

const URL_SCHEME = /^[a-z][a-z0-9+.-]*:\/\//i;
const TRAILING_DOTS = /\.+$/;

/**
* Extracts the hostname from an absolute URL or a bare host, lowercased and
* without a trailing root dot. Returns null for empty or unparsable input.
*/
export function browserHostFromTarget(target: string): string | null {
const trimmed = target.trim();
if (trimmed.length === 0) {
return null;
}
const candidate = URL_SCHEME.test(trimmed) ? trimmed : `https://${trimmed}`;
let hostname: string;
try {
hostname = new URL(candidate).hostname;
} catch {
return null;
}
const normalized = hostname.toLowerCase().replace(TRAILING_DOTS, "");
return normalized.length > 0 ? normalized : null;
}

/** True when the target's hostname is a name under the `.local` suffix. */
export function isLocalSuffixTarget(target: string): boolean {
const host = browserHostFromTarget(target);
return host?.endsWith(".local") === true;
}

/** True when the hint applies to any target on the given platform. */
export function shouldShowLocalNetworkHint(opts: {
readonly platform: string;
readonly targets: Iterable<string>;
}): boolean {
if (opts.platform !== "darwin") {
return false;
}
for (const target of opts.targets) {
if (isLocalSuffixTarget(target)) {
return true;
}
}
return false;
}

export interface LocalNetworkHintEmitter {
/**
* Records browser-facing URLs or hosts that Hack printed or opened, and
* writes the hint once when one of them needs it. Returns true only for
* the call that wrote the hint.
*/
note(targets: Iterable<string>): boolean;
}

/** Creates an emitter with injected platform and writer (used by tests). */
export function createLocalNetworkHintEmitter(opts: {
readonly platform: string;
readonly write: (line: string) => void;
}): LocalNetworkHintEmitter {
let shown = false;
return {
note(targets) {
if (shown) {
return false;
}
if (!shouldShowLocalNetworkHint({ platform: opts.platform, targets })) {
return false;
}
shown = true;
opts.write(`${LOCAL_NETWORK_HINT_MESSAGE}\n`);
return true;
},
};
}

let processEmitter: LocalNetworkHintEmitter | null = null;

/**
* Process-wide hint for the current command. Each CLI invocation is one
* process, so this shows the hint at most once per command.
*/
export function noteBrowserTargetsForLocalNetworkHint(
targets: Iterable<string>
): boolean {
processEmitter ??= createLocalNetworkHintEmitter({
platform: process.platform,
write: (line) => {
process.stderr.write(line);
},
});
return processEmitter.note(targets);
}
11 changes: 11 additions & 0 deletions tests/helpers/darwin-platform-preload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/**
* Bun `--preload` module for CLI subprocess tests: reports the platform as
* macOS so platform-gated output (such as the Local Network hint) is
* exercised on any host. It changes only `process.platform`; callers must
* also put stub executables (for example `open`) first on PATH so nothing
* real launches.
*/
Object.defineProperty(process, "platform", {
value: "darwin",
configurable: true,
});
Loading
Loading