@hasna/recordings 0.3.2 → 0.3.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +38 -34
- package/bun.lock +96 -36
- package/dist/__tests__/helpers/source-assertions.d.ts +6 -4
- package/dist/__tests__/helpers/source-assertions.d.ts.map +1 -1
- package/dist/cli/index.js +1059 -219
- package/dist/cli/macos-shortcut.d.ts +2 -2
- package/dist/db/pg-migrations.d.ts +1 -1
- package/dist/http/client.d.ts +6 -14
- package/dist/http/client.d.ts.map +1 -1
- package/dist/index.js +1971 -175
- package/dist/lib/capture-probe.d.ts +3 -3
- package/dist/lib/capture-probe.d.ts.map +1 -1
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/macos-bundle.d.ts +1 -1
- package/dist/lib/release-install-policy.d.ts +21 -0
- package/dist/lib/release-install-policy.d.ts.map +1 -1
- package/dist/mcp/index.js +978 -142
- package/dist/sdk/index.d.ts +2 -2
- package/dist/sdk/index.js +7 -3
- package/dist/sdk/v1.generated.d.ts.map +1 -1
- package/dist/server/cloud-config.d.ts +4 -19
- package/dist/server/cloud-config.d.ts.map +1 -1
- package/dist/server/cloud.d.ts +1 -1
- package/dist/server/cloud.d.ts.map +1 -1
- package/dist/server/index.js +217 -299
- package/dist/server/serve.d.ts.map +1 -1
- package/dist/storage.d.ts +1 -1
- package/dist/storage.d.ts.map +1 -1
- package/dist/storage.js +1881 -90
- package/dist/store.d.ts.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.d.ts.map +1 -1
- package/package.json +4 -3
- package/packaging/macos/build_release_pkg.sh +10 -4
- package/packaging/macos/managed_bootstrap.sh +4 -4
- package/packaging/macos/scripts/postinstall +2 -2
- package/packaging/macos/scripts/preinstall +2 -0
- package/scripts/generate-sdk.ts +17 -16
- package/scripts/install_macos_app.sh +22 -22
- package/scripts/macos_artifact.ts +48 -39
- package/scripts/migrate.ts +2 -2
- package/scripts/release-suite-gate.ts +174 -0
- package/scripts/set-version.ts +5 -5
- package/scripts/smoke_macos_app.sh +21 -21
- package/scripts/vacuity-manifests/version-sites.tsv +8 -4
- package/src/native/Recordings/RecordingsLib/Info.plist +3 -3
- package/src/native/Recordings/RecordingsTests/CLIRunnerTests.swift +2 -2
- package/src/native/Recordings/RecordingsTests/RecordingStartGateTests.swift +1 -1
- package/src/native/Recordings/Updater/BootstrapPreflight/BootstrapPreflightMain.swift +1 -1
- package/src/native/Recordings/Updater/Broker/ApplicationNamespace.swift +2 -2
- package/src/native/Recordings/Updater/Broker/BrokerMain.swift +1 -1
- package/src/native/Recordings/Updater/Broker/CanonicalTreeCopy.swift +1 -1
- package/src/native/Recordings/Updater/Broker/InstallJournal.swift +1 -1
- package/src/native/Recordings/Updater/BrokerTests/ActivationRecoveryPolicyTests.swift +2 -2
- package/src/native/Recordings/Updater/Protocol/UpdateProtocol.swift +1 -1
- package/src/native/Recordings/Updater/VerifierLauncher/RecordingsVerifierLauncher.c +2 -2
- package/src/native/Recordings/build.sh +14 -20
- package/dist/lib/retired-deployment-modes.d.ts +0 -24
- package/dist/lib/retired-deployment-modes.d.ts.map +0 -1
package/dist/cli/index.js
CHANGED
|
@@ -903,7 +903,7 @@ import chalk from "chalk";
|
|
|
903
903
|
import { spawnSync as spawnSync7 } from "child_process";
|
|
904
904
|
import {
|
|
905
905
|
existsSync as existsSync8,
|
|
906
|
-
readFileSync as
|
|
906
|
+
readFileSync as readFileSync5,
|
|
907
907
|
readdirSync as readdirSync2
|
|
908
908
|
} from "fs";
|
|
909
909
|
import { dirname as dirname5, join as pathJoin } from "path";
|
|
@@ -1925,9 +1925,118 @@ function setAgentFocus(idOrName, projectId, db) {
|
|
|
1925
1925
|
d.query("UPDATE agents SET active_project_id = ?, last_seen_at = ? WHERE id = ?").run(resolvedProjectId, new Date().toISOString(), agent.id);
|
|
1926
1926
|
return getAgent(agent.id, d);
|
|
1927
1927
|
}
|
|
1928
|
+
// package.json
|
|
1929
|
+
var package_default = {
|
|
1930
|
+
name: "@hasna/recordings",
|
|
1931
|
+
version: "0.3.9",
|
|
1932
|
+
type: "module",
|
|
1933
|
+
description: "Speech-to-text recording tool with MCP and CLI \u2014 records, transcribes, and optionally enhances text using AI",
|
|
1934
|
+
repository: {
|
|
1935
|
+
type: "git",
|
|
1936
|
+
url: "git+https://github.com/hasna/apps"
|
|
1937
|
+
},
|
|
1938
|
+
main: "dist/index.js",
|
|
1939
|
+
types: "dist/index.d.ts",
|
|
1940
|
+
bin: {
|
|
1941
|
+
recordings: "dist/cli/index.js",
|
|
1942
|
+
"recordings-mcp": "dist/mcp/index.js",
|
|
1943
|
+
"recordings-serve": "dist/server/index.js"
|
|
1944
|
+
},
|
|
1945
|
+
exports: {
|
|
1946
|
+
".": {
|
|
1947
|
+
import: "./dist/index.js",
|
|
1948
|
+
types: "./dist/index.d.ts"
|
|
1949
|
+
},
|
|
1950
|
+
"./storage": {
|
|
1951
|
+
import: "./dist/storage.js",
|
|
1952
|
+
types: "./dist/storage.d.ts"
|
|
1953
|
+
},
|
|
1954
|
+
"./sdk": {
|
|
1955
|
+
import: "./dist/sdk/index.js",
|
|
1956
|
+
types: "./dist/sdk/index.d.ts"
|
|
1957
|
+
}
|
|
1958
|
+
},
|
|
1959
|
+
engines: {
|
|
1960
|
+
bun: ">=1.0.0"
|
|
1961
|
+
},
|
|
1962
|
+
scripts: {
|
|
1963
|
+
clean: "rm -rf dist",
|
|
1964
|
+
build: "bun run clean && bun run build:cli && bun run build:mcp && bun run build:serve && bun run build:lib && tsc --emitDeclarationOnly --outDir dist",
|
|
1965
|
+
"build:cli": "bun build src/cli/index.ts --target=bun --outfile=dist/cli/index.js --external=commander --external=chalk --external=openai",
|
|
1966
|
+
"build:mcp": "bun build src/mcp/index.ts --target=bun --outfile=dist/mcp/index.js --external=@modelcontextprotocol/sdk --external=openai",
|
|
1967
|
+
"build:serve": "bun build src/server/index.ts --target=bun --outfile=dist/server/index.js --external=@modelcontextprotocol/sdk --external=@hasna/contracts --external=openai --external=pg",
|
|
1968
|
+
"build:lib": "bun build src/index.ts src/storage.ts src/sdk/index.ts --target=bun --outdir=dist --external=openai",
|
|
1969
|
+
"build:native-fs-guard": "/bin/bash scripts/build_native_fs_guard.sh",
|
|
1970
|
+
"generate:sdk": "bun run scripts/generate-sdk.ts",
|
|
1971
|
+
migrate: "bun run scripts/migrate.ts",
|
|
1972
|
+
typecheck: "tsc --noEmit",
|
|
1973
|
+
"typecheck:tcc-contract": "tsc --noEmit -p tsconfig.tcc-contract.json",
|
|
1974
|
+
test: "bun test",
|
|
1975
|
+
"verify:ci-suite": "bun scripts/ci-linux-suite.ts --check",
|
|
1976
|
+
"verify:ci-suite:run": "bun scripts/ci-linux-suite.ts --verify-run",
|
|
1977
|
+
"verify:ci-native": "bun scripts/ci-native-build.ts",
|
|
1978
|
+
"test:gated": 'bun scripts/ci-linux-suite.ts --check && RECORDINGS_TEST_TIMEOUT_MS="${RECORDINGS_TEST_TIMEOUT_MS:-120000}" bun test --timeout "$RECORDINGS_TEST_TIMEOUT_MS" $(bun scripts/ci-linux-suite.ts --gated)',
|
|
1979
|
+
"test:vacuity-battery": 'bun scripts/vacuity-manifest-gen.ts > "${TMPDIR:-/tmp}/vacuity-corrupt-sites.tsv" && bun scripts/vacuity-mutation-battery.ts "${TMPDIR:-/tmp}/vacuity-corrupt-sites.tsv"',
|
|
1980
|
+
"test:vacuity-battery:source": "bun scripts/vacuity-mutation-battery.ts scripts/vacuity-manifests/source-side.tsv",
|
|
1981
|
+
"test:vacuity-battery:chain": "bun scripts/vacuity-mutation-battery.ts scripts/vacuity-manifests/install-chain.tsv",
|
|
1982
|
+
"test:vacuity-battery:vars": "bun scripts/vacuity-mutation-battery.ts scripts/vacuity-manifests/variable-operands.tsv",
|
|
1983
|
+
"test:vacuity-battery:reorder": "bun scripts/vacuity-mutation-battery.ts scripts/vacuity-manifests/reorder.tsv",
|
|
1984
|
+
"test:coverage": "bun test --coverage",
|
|
1985
|
+
"dev:cli": "bun run src/cli/index.ts",
|
|
1986
|
+
"desktop:snapshot": "bun run src/cli/index.ts app snapshot",
|
|
1987
|
+
"dev:mcp": "bun run src/mcp/index.ts",
|
|
1988
|
+
"verify:release": "bun run scripts/release-guard.ts",
|
|
1989
|
+
"scan:artifact": "bun run scripts/scan-artifact.ts",
|
|
1990
|
+
"version:set": "bun run scripts/set-version.ts",
|
|
1991
|
+
"version:check": "bun run scripts/set-version.ts --check",
|
|
1992
|
+
prepack: "bun run prepack:platform-gate && bun run version:check && bun run build && bun run verify:release && bun run scan:artifact",
|
|
1993
|
+
"prepack:platform-gate": `bash -c 'if [ "$(uname -s)" = Darwin ]; then bun run build:native-fs-guard; else echo "WARN: native fs-guard build skipped on $(uname -s) \u2014 dry-run/CI pack; publish recordings from macOS"; fi'`,
|
|
1994
|
+
prepublishOnly: "bun run typecheck && bun run release-suite-gate",
|
|
1995
|
+
"release-suite-gate": "bun run scripts/release-suite-gate.ts",
|
|
1996
|
+
"typecheck:shortcut-contract": "tsc --noEmit -p tsconfig.test.json"
|
|
1997
|
+
},
|
|
1998
|
+
files: [
|
|
1999
|
+
"dist/",
|
|
2000
|
+
"scripts/",
|
|
2001
|
+
"Dockerfile.package",
|
|
2002
|
+
"bun.lock",
|
|
2003
|
+
"src/native/Recordings/App/",
|
|
2004
|
+
"src/native/Recordings/RecordingsLib/",
|
|
2005
|
+
"src/native/Recordings/RecordingsTests/",
|
|
2006
|
+
"src/native/Recordings/Updater/",
|
|
2007
|
+
"src/native/Recordings/Package.swift",
|
|
2008
|
+
"src/native/Recordings/Package.resolved",
|
|
2009
|
+
"src/native/Recordings/build.sh",
|
|
2010
|
+
"packaging/macos/",
|
|
2011
|
+
"README.md",
|
|
2012
|
+
"LICENSE"
|
|
2013
|
+
],
|
|
2014
|
+
dependencies: {
|
|
2015
|
+
"@hasna/contracts": "0.13.4",
|
|
2016
|
+
"@hasna/events": "0.1.11",
|
|
2017
|
+
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
2018
|
+
chalk: "^5.4.1",
|
|
2019
|
+
commander: "^13.1.0",
|
|
2020
|
+
openai: "^5.1.0",
|
|
2021
|
+
pg: "^8.13.3",
|
|
2022
|
+
zod: "^3.24.2"
|
|
2023
|
+
},
|
|
2024
|
+
devDependencies: {
|
|
2025
|
+
"@types/bun": "^1.2.5",
|
|
2026
|
+
"@types/pg": "^8.11.11",
|
|
2027
|
+
"node-api-headers": "1.9.0",
|
|
2028
|
+
typescript: "^5.8.2"
|
|
2029
|
+
},
|
|
2030
|
+
publishConfig: {
|
|
2031
|
+
registry: "https://registry.npmjs.org",
|
|
2032
|
+
access: "public"
|
|
2033
|
+
},
|
|
2034
|
+
license: "Apache-2.0",
|
|
2035
|
+
author: "Hasna <andrei@hasna.com>"
|
|
2036
|
+
};
|
|
1928
2037
|
|
|
1929
2038
|
// src/version.ts
|
|
1930
|
-
var VERSION =
|
|
2039
|
+
var VERSION = package_default.version;
|
|
1931
2040
|
|
|
1932
2041
|
// src/db/feedback.ts
|
|
1933
2042
|
function saveFeedback(input) {
|
|
@@ -1935,59 +2044,207 @@ function saveFeedback(input) {
|
|
|
1935
2044
|
db.query("INSERT INTO feedback (message, email, category, version) VALUES (?, ?, ?, ?)").run(input.message, input.email ?? null, input.category ?? "general", input.version ?? VERSION);
|
|
1936
2045
|
}
|
|
1937
2046
|
|
|
1938
|
-
//
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
"
|
|
1944
|
-
"hybrid"
|
|
1945
|
-
];
|
|
1946
|
-
function normalizeModeToken(value) {
|
|
1947
|
-
return value.trim().toLowerCase().replace(/-/g, "_");
|
|
2047
|
+
// ../contracts/dist/client/transport.js
|
|
2048
|
+
import { isIP } from "net";
|
|
2049
|
+
import { readFileSync as readFileSync2, statSync as statSync2 } from "fs";
|
|
2050
|
+
import { join as join4 } from "path";
|
|
2051
|
+
function envToken(name) {
|
|
2052
|
+
return name.toUpperCase().replace(/-/g, "_");
|
|
1948
2053
|
}
|
|
1949
|
-
function
|
|
1950
|
-
const
|
|
1951
|
-
return
|
|
2054
|
+
function clientTransportEnvKeys(name) {
|
|
2055
|
+
const envSegment = envToken(name);
|
|
2056
|
+
return {
|
|
2057
|
+
apiUrlKeys: [`HASNA_${envSegment}_API_URL`, `${envSegment}_API_URL`],
|
|
2058
|
+
apiKeyKeys: [`HASNA_${envSegment}_API_KEY`, `${envSegment}_API_KEY`]
|
|
2059
|
+
};
|
|
1952
2060
|
}
|
|
1953
|
-
function
|
|
1954
|
-
|
|
1955
|
-
const value = mode === "local" ? replacement.onBox : replacement.offBox;
|
|
1956
|
-
return new Error(`${sourceEnvKey}=${rawValue} names a deployment mode, and deployment modes are removed: ` + `${RETIRED_DEPLOYMENT_MODES.join(" | ")} no longer select anything. ` + `Set ${replacement.envKey}=${value} instead ` + `(${replacement.envKey} takes ${replacement.onBox} or ${replacement.offBox}).`);
|
|
2061
|
+
function credentialOverrideEnvKey(name) {
|
|
2062
|
+
return `HASNA_${envToken(name)}_API_KEY_OVERRIDE`;
|
|
1957
2063
|
}
|
|
2064
|
+
var CREDENTIAL_PROFILE_ENV_KEY = "HASNA_PROFILE";
|
|
1958
2065
|
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
2066
|
+
class CredentialResolutionError extends Error {
|
|
2067
|
+
appName;
|
|
2068
|
+
attempted;
|
|
2069
|
+
constructor(appName, message, attempted) {
|
|
2070
|
+
super(message);
|
|
2071
|
+
this.name = "CredentialResolutionError";
|
|
2072
|
+
this.appName = appName;
|
|
2073
|
+
this.attempted = attempted;
|
|
2074
|
+
}
|
|
2075
|
+
}
|
|
2076
|
+
var HASNA_STATE_DIR = ".hasna";
|
|
2077
|
+
var FLEET_CREDENTIAL_DIR = "cloud";
|
|
2078
|
+
var CONFIG_DIR = ".config";
|
|
2079
|
+
var CONFIG_NAMESPACE = "hasna";
|
|
2080
|
+
var MAX_CREDENTIAL_FILE_BYTES = 64 * 1024;
|
|
2081
|
+
var SAFE_APP_SLUG = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
|
|
2082
|
+
var SAFE_PROFILE = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/;
|
|
2083
|
+
var ILLEGAL_IN_HEADER_VALUE = /[^\t\x20-\x7e]/;
|
|
2084
|
+
function homeDir(env) {
|
|
2085
|
+
const home = env.HOME?.trim();
|
|
2086
|
+
return home ? home : null;
|
|
2087
|
+
}
|
|
2088
|
+
function credentialDiskSources(name, env) {
|
|
2089
|
+
return profileDiskSources(name, env, null);
|
|
2090
|
+
}
|
|
2091
|
+
function profileDiskSources(name, env, profile) {
|
|
2092
|
+
const home = homeDir(env);
|
|
2093
|
+
if (!home || !SAFE_APP_SLUG.test(name))
|
|
2094
|
+
return [];
|
|
2095
|
+
const stem = profile ? `${name}.${profile}` : name;
|
|
2096
|
+
const configStem = profile ? `${name}-${profile}` : name;
|
|
2097
|
+
return [
|
|
2098
|
+
join4(home, HASNA_STATE_DIR, FLEET_CREDENTIAL_DIR, `${stem}.env`),
|
|
2099
|
+
join4(home, CONFIG_DIR, CONFIG_NAMESPACE, `${configStem}-cloud.env`)
|
|
2100
|
+
];
|
|
1963
2101
|
}
|
|
1964
|
-
function
|
|
1965
|
-
|
|
2102
|
+
function parseEnvFile(text) {
|
|
2103
|
+
const values = new Map;
|
|
2104
|
+
for (const rawLine of text.split(/\r?\n/)) {
|
|
2105
|
+
const line = rawLine.trim();
|
|
2106
|
+
if (line.length === 0 || line.startsWith("#"))
|
|
2107
|
+
continue;
|
|
2108
|
+
const withoutExport = line.startsWith("export ") ? line.slice("export ".length).trim() : line;
|
|
2109
|
+
const equals = withoutExport.indexOf("=");
|
|
2110
|
+
if (equals <= 0)
|
|
2111
|
+
continue;
|
|
2112
|
+
const key = withoutExport.slice(0, equals).trim();
|
|
2113
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
|
|
2114
|
+
continue;
|
|
2115
|
+
let value = withoutExport.slice(equals + 1).trim();
|
|
2116
|
+
const quote = value[0];
|
|
2117
|
+
if (quote === '"' || quote === "'") {
|
|
2118
|
+
if (value.length < 2 || !value.endsWith(quote))
|
|
2119
|
+
continue;
|
|
2120
|
+
value = value.slice(1, -1);
|
|
2121
|
+
}
|
|
2122
|
+
if (value.length === 0)
|
|
2123
|
+
continue;
|
|
2124
|
+
values.set(key, value);
|
|
2125
|
+
}
|
|
2126
|
+
return values;
|
|
1966
2127
|
}
|
|
1967
|
-
function
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
2128
|
+
function readAppConfigFile(path) {
|
|
2129
|
+
let text;
|
|
2130
|
+
try {
|
|
2131
|
+
const stats = statSync2(path);
|
|
2132
|
+
if (!stats.isFile() || stats.size > MAX_CREDENTIAL_FILE_BYTES)
|
|
2133
|
+
return null;
|
|
2134
|
+
text = readFileSync2(path, "utf8");
|
|
2135
|
+
} catch {
|
|
2136
|
+
return null;
|
|
1975
2137
|
}
|
|
1976
|
-
|
|
2138
|
+
return parseEnvFile(text);
|
|
1977
2139
|
}
|
|
1978
|
-
function
|
|
1979
|
-
|
|
2140
|
+
function readCredentialFile(path, apiKeyKeys) {
|
|
2141
|
+
const values = readAppConfigFile(path);
|
|
2142
|
+
if (!values)
|
|
2143
|
+
return null;
|
|
2144
|
+
for (const key of apiKeyKeys) {
|
|
2145
|
+
const value = values.get(key)?.trim();
|
|
2146
|
+
if (value)
|
|
2147
|
+
return value;
|
|
2148
|
+
}
|
|
2149
|
+
return null;
|
|
1980
2150
|
}
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
2151
|
+
var CREDENTIAL_SHAPED_KEY = /(?:^|_)(?:API_KEY|KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|AUTH)(?:_|$)/;
|
|
2152
|
+
function appConfigDiskValue(name, env, keys) {
|
|
2153
|
+
const wanted = keys.filter((key) => !CREDENTIAL_SHAPED_KEY.test(key));
|
|
2154
|
+
if (wanted.length === 0)
|
|
2155
|
+
return null;
|
|
2156
|
+
for (const path of credentialDiskSources(name, env)) {
|
|
2157
|
+
const values = readAppConfigFile(path);
|
|
2158
|
+
if (!values)
|
|
2159
|
+
continue;
|
|
2160
|
+
for (const key of wanted) {
|
|
2161
|
+
const value = values.get(key)?.trim();
|
|
2162
|
+
if (value)
|
|
2163
|
+
return { key, value, path };
|
|
2164
|
+
}
|
|
2165
|
+
}
|
|
2166
|
+
return null;
|
|
2167
|
+
}
|
|
2168
|
+
function assertUsableCredential(appName, source, value) {
|
|
2169
|
+
if (!ILLEGAL_IN_HEADER_VALUE.test(value))
|
|
2170
|
+
return;
|
|
2171
|
+
throw new CredentialResolutionError(appName, `The credential from ${source} contains characters that cannot be sent in an HTTP header ` + `(a control character or non-ASCII byte). A file written with CR-only line endings is the usual ` + `cause. Rewrite that credential file with one LF-terminated KEY=value line. ` + `The value is not shown here, and is deliberately never logged.`, [source]);
|
|
2172
|
+
}
|
|
2173
|
+
var INSPECT_CUSTOM = Symbol.for("nodejs.util.inspect.custom");
|
|
2174
|
+
var CREDENTIAL_SEAL = Symbol.for("hasna:contracts:sealedCredential");
|
|
2175
|
+
var CALLER_SUPPLIED_CREDENTIAL_PROVIDER_SOURCE = "caller-supplied CredentialProvider";
|
|
2176
|
+
function sealCredential(fields) {
|
|
2177
|
+
const { apiKey } = fields;
|
|
2178
|
+
const visible = {
|
|
2179
|
+
tier: fields.tier,
|
|
2180
|
+
source: fields.source,
|
|
2181
|
+
deliberate: fields.deliberate,
|
|
2182
|
+
deprecated: fields.deprecated,
|
|
2183
|
+
diskCandidates: Object.freeze([...fields.diskCandidates]),
|
|
2184
|
+
warning: fields.warning
|
|
1988
2185
|
};
|
|
2186
|
+
const sealed = { ...visible };
|
|
2187
|
+
Object.defineProperty(sealed, "apiKey", {
|
|
2188
|
+
value: apiKey,
|
|
2189
|
+
enumerable: false,
|
|
2190
|
+
writable: false,
|
|
2191
|
+
configurable: false
|
|
2192
|
+
});
|
|
2193
|
+
Object.defineProperty(sealed, INSPECT_CUSTOM, {
|
|
2194
|
+
value: () => ({ ...visible, apiKey: "[redacted]" }),
|
|
2195
|
+
enumerable: false,
|
|
2196
|
+
writable: false,
|
|
2197
|
+
configurable: false
|
|
2198
|
+
});
|
|
2199
|
+
Object.defineProperty(sealed, CREDENTIAL_SEAL, {
|
|
2200
|
+
value: true,
|
|
2201
|
+
enumerable: false,
|
|
2202
|
+
writable: false,
|
|
2203
|
+
configurable: false
|
|
2204
|
+
});
|
|
2205
|
+
return Object.freeze(sealed);
|
|
2206
|
+
}
|
|
2207
|
+
function isSealedCredential(credential) {
|
|
2208
|
+
return credential[CREDENTIAL_SEAL] === true;
|
|
2209
|
+
}
|
|
2210
|
+
function explicitCredential(appName, apiKey) {
|
|
2211
|
+
const source = "explicit apiKey option";
|
|
2212
|
+
assertUsableCredential(appName, source, apiKey);
|
|
2213
|
+
return sealCredential({
|
|
2214
|
+
apiKey,
|
|
2215
|
+
tier: "argument",
|
|
2216
|
+
source,
|
|
2217
|
+
deliberate: true,
|
|
2218
|
+
deprecated: false,
|
|
2219
|
+
diskCandidates: [],
|
|
2220
|
+
warning: null
|
|
2221
|
+
});
|
|
1989
2222
|
}
|
|
1990
|
-
function
|
|
2223
|
+
function validateAndSealResolvedCredential(appName, credential) {
|
|
2224
|
+
const apiKey = credential.apiKey;
|
|
2225
|
+
assertUsableCredential(appName, CALLER_SUPPLIED_CREDENTIAL_PROVIDER_SOURCE, apiKey);
|
|
2226
|
+
if (!isSealedCredential(credential)) {
|
|
2227
|
+
return sealCredential({
|
|
2228
|
+
apiKey,
|
|
2229
|
+
tier: "argument",
|
|
2230
|
+
source: CALLER_SUPPLIED_CREDENTIAL_PROVIDER_SOURCE,
|
|
2231
|
+
deliberate: true,
|
|
2232
|
+
deprecated: false,
|
|
2233
|
+
diskCandidates: [],
|
|
2234
|
+
warning: null
|
|
2235
|
+
});
|
|
2236
|
+
}
|
|
2237
|
+
return sealCredential({
|
|
2238
|
+
apiKey,
|
|
2239
|
+
tier: credential.tier,
|
|
2240
|
+
source: credential.source,
|
|
2241
|
+
deliberate: credential.deliberate,
|
|
2242
|
+
deprecated: credential.deprecated,
|
|
2243
|
+
diskCandidates: credential.diskCandidates,
|
|
2244
|
+
warning: credential.warning
|
|
2245
|
+
});
|
|
2246
|
+
}
|
|
2247
|
+
function firstEnvValue(env, keys) {
|
|
1991
2248
|
for (const key of keys) {
|
|
1992
2249
|
const value = env[key]?.trim();
|
|
1993
2250
|
if (value)
|
|
@@ -1995,71 +2252,343 @@ function firstEnv(env, keys) {
|
|
|
1995
2252
|
}
|
|
1996
2253
|
return null;
|
|
1997
2254
|
}
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2255
|
+
var DEPRECATION_REGISTRY = Symbol.for("hasna:contracts:credentialDeprecationNotices");
|
|
2256
|
+
function deprecationNotified() {
|
|
2257
|
+
const host = globalThis;
|
|
2258
|
+
const existing = host[DEPRECATION_REGISTRY];
|
|
2259
|
+
if (existing instanceof Set)
|
|
2260
|
+
return existing;
|
|
2261
|
+
const created = new Set;
|
|
2262
|
+
host[DEPRECATION_REGISTRY] = created;
|
|
2263
|
+
return created;
|
|
2264
|
+
}
|
|
2265
|
+
function defaultDeprecationSink(message) {
|
|
2266
|
+
if (typeof process !== "undefined" && process.stderr) {
|
|
2267
|
+
process.stderr.write(`${message}
|
|
2268
|
+
`);
|
|
2269
|
+
}
|
|
2270
|
+
}
|
|
2271
|
+
function resolveCredential(name, env, options = {}) {
|
|
2272
|
+
const { apiKeyKeys } = clientTransportEnvKeys(name);
|
|
2273
|
+
const diskPaths = credentialDiskSources(name, env);
|
|
2274
|
+
const explicitKey = options.apiKey?.trim();
|
|
2275
|
+
if (explicitKey) {
|
|
2276
|
+
assertUsableCredential(name, "the explicit apiKey argument", explicitKey);
|
|
2277
|
+
return sealCredential({
|
|
2278
|
+
apiKey: explicitKey,
|
|
2279
|
+
tier: "argument",
|
|
2280
|
+
source: "explicit apiKey argument",
|
|
2281
|
+
deliberate: true,
|
|
2282
|
+
deprecated: false,
|
|
2283
|
+
diskCandidates: diskPaths,
|
|
2284
|
+
warning: null
|
|
2285
|
+
});
|
|
2286
|
+
}
|
|
2287
|
+
const overrideKeyName = credentialOverrideEnvKey(name);
|
|
2288
|
+
const overrideRaw = env[overrideKeyName];
|
|
2289
|
+
if (overrideRaw !== undefined) {
|
|
2290
|
+
const override = overrideRaw.trim();
|
|
2291
|
+
if (!override) {
|
|
2292
|
+
throw new CredentialResolutionError(name, `${overrideKeyName} is set but empty. It is a deliberate override, so it is not resolved around: ` + `either give it a real key or unset it to fall back to the credential on disk.`, [overrideKeyName]);
|
|
2293
|
+
}
|
|
2294
|
+
assertUsableCredential(name, overrideKeyName, override);
|
|
2295
|
+
return sealCredential({
|
|
2296
|
+
apiKey: override,
|
|
2297
|
+
tier: "override",
|
|
2298
|
+
source: overrideKeyName,
|
|
2299
|
+
deliberate: true,
|
|
2300
|
+
deprecated: false,
|
|
2301
|
+
diskCandidates: diskPaths,
|
|
2302
|
+
warning: null
|
|
2303
|
+
});
|
|
2304
|
+
}
|
|
2305
|
+
const profile = options.profile?.trim() || env[CREDENTIAL_PROFILE_ENV_KEY]?.trim();
|
|
2306
|
+
if (profile) {
|
|
2307
|
+
const profileSource = options.profile?.trim() ? "explicit profile argument" : CREDENTIAL_PROFILE_ENV_KEY;
|
|
2308
|
+
if (!SAFE_PROFILE.test(profile)) {
|
|
2309
|
+
throw new CredentialResolutionError(name, `Profile name from ${profileSource} is not usable in a path. ` + `Use letters, digits, dot, dash, or underscore.`, [profileSource]);
|
|
2310
|
+
}
|
|
2311
|
+
const paths = profileDiskSources(name, env, profile);
|
|
2312
|
+
for (const path of paths) {
|
|
2313
|
+
const value = readCredentialFile(path, apiKeyKeys);
|
|
2314
|
+
if (value) {
|
|
2315
|
+
assertUsableCredential(name, path, value);
|
|
2316
|
+
return sealCredential({
|
|
2317
|
+
apiKey: value,
|
|
2318
|
+
tier: "profile",
|
|
2319
|
+
source: path,
|
|
2320
|
+
deliberate: true,
|
|
2321
|
+
deprecated: false,
|
|
2322
|
+
diskCandidates: paths,
|
|
2323
|
+
warning: null
|
|
2324
|
+
});
|
|
2325
|
+
}
|
|
2326
|
+
}
|
|
2327
|
+
throw new CredentialResolutionError(name, `Profile '${profile}' (from ${profileSource}) has no ${apiKeyKeys[0]} for '${name}'. ` + `Looked in: ${paths.join(", ") || "<no HOME in this environment>"}. ` + `A profile names WHICH identity to use, so it is never resolved around \u2014 ` + `create the profile's credential file or unset ${CREDENTIAL_PROFILE_ENV_KEY}.`, paths);
|
|
2328
|
+
}
|
|
2329
|
+
const diskHits = diskPaths.map((path) => ({ path, value: readCredentialFile(path, apiKeyKeys) })).filter((hit) => hit.value !== null);
|
|
2330
|
+
if (diskHits.length > 0) {
|
|
2331
|
+
const winner = diskHits[0];
|
|
2332
|
+
assertUsableCredential(name, winner.path, winner.value);
|
|
2333
|
+
const divergentSources = [
|
|
2334
|
+
...diskHits.slice(1).filter((hit) => hit.value !== winner.value).map((hit) => hit.path),
|
|
2335
|
+
...(() => {
|
|
2336
|
+
const legacyHit = firstEnvValue(env, apiKeyKeys);
|
|
2337
|
+
return legacyHit && legacyHit.value !== winner.value ? [legacyHit.key] : [];
|
|
2338
|
+
})()
|
|
2339
|
+
];
|
|
2340
|
+
const warning = divergentSources.length > 0 ? `Credential sources disagree for '${name}': ${winner.path} and ` + `${divergentSources.join(", ")} hold different keys. ${winner.path} wins, because a file on ` + `disk is re-read on every call while an environment variable is a snapshot. Reconcile them \u2014 ` + `a rotation that updated only one leaves the other to fail 401 wherever it is loaded first.` : null;
|
|
2341
|
+
return sealCredential({
|
|
2342
|
+
apiKey: winner.value,
|
|
2343
|
+
tier: "disk",
|
|
2344
|
+
source: winner.path,
|
|
2345
|
+
deliberate: false,
|
|
2346
|
+
deprecated: false,
|
|
2347
|
+
diskCandidates: diskPaths,
|
|
2348
|
+
warning
|
|
2349
|
+
});
|
|
2350
|
+
}
|
|
2351
|
+
const legacy = firstEnvValue(env, apiKeyKeys);
|
|
2352
|
+
if (legacy) {
|
|
2353
|
+
assertUsableCredential(name, legacy.key, legacy.value);
|
|
2354
|
+
const where = diskPaths.length > 0 ? `Put the current key in ${diskPaths[0]} \u2014 it is re-read on every call, so rotations take effect immediately.` : `This environment has no HOME, so no credential file could be consulted at all; the disk tier is ` + `unavailable here and this process will keep using the environment snapshot.`;
|
|
2355
|
+
const message = `[${name}] DEPRECATED: the API key came from ${legacy.key} in this process's environment. ` + `Environment variables are a snapshot taken when this process started, so a shell that started ` + `before a key rotation keeps using the old key until it exits. ${where}`;
|
|
2356
|
+
const sink = options.onDeprecation ?? defaultDeprecationSink;
|
|
2357
|
+
const notified = deprecationNotified();
|
|
2358
|
+
if (!notified.has(name)) {
|
|
2359
|
+
notified.add(name);
|
|
2360
|
+
sink(message);
|
|
2008
2361
|
}
|
|
2009
|
-
|
|
2362
|
+
return sealCredential({
|
|
2363
|
+
apiKey: legacy.value,
|
|
2364
|
+
tier: "legacy-env",
|
|
2365
|
+
source: legacy.key,
|
|
2366
|
+
deliberate: false,
|
|
2367
|
+
deprecated: true,
|
|
2368
|
+
diskCandidates: diskPaths,
|
|
2369
|
+
warning: message
|
|
2370
|
+
});
|
|
2010
2371
|
}
|
|
2372
|
+
return null;
|
|
2373
|
+
}
|
|
2374
|
+
var ASCII_CONTROL_PATTERN = /[\u0000-\u001f\u007f]/;
|
|
2375
|
+
var DNS_LABEL_PATTERN = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
|
|
2376
|
+
function isValidDnsDomain(value) {
|
|
2377
|
+
if (value.length === 0 || value.length > 253 || ASCII_CONTROL_PATTERN.test(value) || /[^\x00-\x7f]/.test(value)) {
|
|
2378
|
+
return false;
|
|
2379
|
+
}
|
|
2380
|
+
return value.split(".").every((label) => label.length <= 63 && !label.startsWith("xn--") && DNS_LABEL_PATTERN.test(label));
|
|
2381
|
+
}
|
|
2382
|
+
function firstEnv(env, keys, options = {}) {
|
|
2383
|
+
for (const key of keys) {
|
|
2384
|
+
const raw = env[key];
|
|
2385
|
+
const value = raw?.trim();
|
|
2386
|
+
if (value)
|
|
2387
|
+
return { key, value: options.preserveRaw ? raw : value };
|
|
2388
|
+
}
|
|
2389
|
+
return null;
|
|
2390
|
+
}
|
|
2391
|
+
function firstEnvDefinedKey(env, keys) {
|
|
2392
|
+
for (const key of keys) {
|
|
2393
|
+
if (env[key] !== undefined)
|
|
2394
|
+
return key;
|
|
2395
|
+
}
|
|
2396
|
+
return null;
|
|
2397
|
+
}
|
|
2398
|
+
function rawAuthority(value) {
|
|
2399
|
+
const match = /^[a-z][a-z0-9+.-]*:\/\//i.exec(value);
|
|
2400
|
+
if (!match)
|
|
2401
|
+
throw new Error("API URL must be absolute.");
|
|
2402
|
+
const afterScheme = value.slice(match[0].length);
|
|
2403
|
+
const boundary = afterScheme.search(/[/?#]/);
|
|
2404
|
+
const authority = boundary === -1 ? afterScheme : afterScheme.slice(0, boundary);
|
|
2405
|
+
if (!authority)
|
|
2406
|
+
throw new Error("API URL must include a hostname.");
|
|
2407
|
+
return authority;
|
|
2408
|
+
}
|
|
2409
|
+
function assertCanonicalPort(port) {
|
|
2410
|
+
if (!/^[0-9]+$/.test(port) || port.length > 1 && port.startsWith("0")) {
|
|
2411
|
+
throw new Error("API URL authority must contain a canonical port between 1 and 65535.");
|
|
2412
|
+
}
|
|
2413
|
+
const numericPort = Number(port);
|
|
2414
|
+
if (!Number.isSafeInteger(numericPort) || numericPort < 1 || numericPort > 65535) {
|
|
2415
|
+
throw new Error("API URL authority must contain a canonical port between 1 and 65535.");
|
|
2416
|
+
}
|
|
2417
|
+
}
|
|
2418
|
+
function canonicalAuthorityHostname(authority) {
|
|
2419
|
+
let rawHostname;
|
|
2420
|
+
if (authority.startsWith("[")) {
|
|
2421
|
+
const closingBracket = authority.indexOf("]");
|
|
2422
|
+
if (closingBracket === -1) {
|
|
2423
|
+
throw new Error("API URL authority must contain a canonical hostname.");
|
|
2424
|
+
}
|
|
2425
|
+
rawHostname = authority.slice(0, closingBracket + 1);
|
|
2426
|
+
const portSuffix = authority.slice(closingBracket + 1);
|
|
2427
|
+
if (portSuffix) {
|
|
2428
|
+
if (!portSuffix.startsWith(":")) {
|
|
2429
|
+
throw new Error("API URL authority must contain a canonical hostname and port.");
|
|
2430
|
+
}
|
|
2431
|
+
assertCanonicalPort(portSuffix.slice(1));
|
|
2432
|
+
}
|
|
2433
|
+
if (isIP(rawHostname.slice(1, -1)) !== 6) {
|
|
2434
|
+
throw new Error("API URL authority must contain a canonical IPv6 literal.");
|
|
2435
|
+
}
|
|
2436
|
+
} else {
|
|
2437
|
+
const firstColon = authority.indexOf(":");
|
|
2438
|
+
const lastColon = authority.lastIndexOf(":");
|
|
2439
|
+
if (firstColon !== lastColon) {
|
|
2440
|
+
throw new Error("IPv6 API URL authorities must use brackets.");
|
|
2441
|
+
}
|
|
2442
|
+
if (lastColon !== -1) {
|
|
2443
|
+
const port = authority.slice(lastColon + 1);
|
|
2444
|
+
assertCanonicalPort(port);
|
|
2445
|
+
rawHostname = authority.slice(0, lastColon);
|
|
2446
|
+
} else {
|
|
2447
|
+
rawHostname = authority;
|
|
2448
|
+
}
|
|
2449
|
+
const ipVersion = isIP(rawHostname);
|
|
2450
|
+
const numericAddressParts = rawHostname.split(".");
|
|
2451
|
+
const looksLikeNonCanonicalIpv4 = numericAddressParts.every((part) => /^(?:0x[0-9a-f]+|[0-9]+)$/i.test(part));
|
|
2452
|
+
if (ipVersion !== 4 && looksLikeNonCanonicalIpv4 || ipVersion !== 4 && !isValidDnsDomain(rawHostname.toLowerCase())) {
|
|
2453
|
+
throw new Error("API URL authority must contain a canonical ASCII hostname.");
|
|
2454
|
+
}
|
|
2455
|
+
}
|
|
2456
|
+
return rawHostname.toLowerCase();
|
|
2457
|
+
}
|
|
2458
|
+
function isDeliberateLoopbackHttpAuthority(authority) {
|
|
2459
|
+
return /^(?:localhost|127\.0\.0\.1|\[::1\])(?::[0-9]+)?$/i.test(authority);
|
|
2011
2460
|
}
|
|
2012
2461
|
function toV1BaseUrl(apiUrl) {
|
|
2013
|
-
|
|
2462
|
+
if (ASCII_CONTROL_PATTERN.test(apiUrl)) {
|
|
2463
|
+
throw new Error("API URL must not contain ASCII control characters.");
|
|
2464
|
+
}
|
|
2465
|
+
const input = apiUrl.trim();
|
|
2466
|
+
const authority = rawAuthority(input);
|
|
2467
|
+
if (authority.includes("@") || authority.includes("\\") || authority.includes("%") || /[^\x00-\x7f]/.test(authority)) {
|
|
2468
|
+
throw new Error("API URL authority must be canonical ASCII without credentials.");
|
|
2469
|
+
}
|
|
2470
|
+
const canonicalHostname = canonicalAuthorityHostname(authority);
|
|
2471
|
+
const url = new URL(input);
|
|
2014
2472
|
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
2015
2473
|
throw new Error("API URL must use http or https.");
|
|
2016
2474
|
}
|
|
2475
|
+
if (url.username || url.password) {
|
|
2476
|
+
throw new Error("API URL must not include credentials.");
|
|
2477
|
+
}
|
|
2478
|
+
if (!url.hostname || url.hostname.endsWith(".")) {
|
|
2479
|
+
throw new Error("API URL must include a canonical hostname.");
|
|
2480
|
+
}
|
|
2481
|
+
if (url.hostname.toLowerCase() !== canonicalHostname) {
|
|
2482
|
+
throw new Error("API URL authority must not rely on parser hostname normalization.");
|
|
2483
|
+
}
|
|
2484
|
+
if (url.hostname.split(".").some((label) => label.toLowerCase().startsWith("xn--"))) {
|
|
2485
|
+
throw new Error("API URL must not use IDN or punycode hostnames.");
|
|
2486
|
+
}
|
|
2487
|
+
if (url.protocol === "http:" && !isDeliberateLoopbackHttpAuthority(authority)) {
|
|
2488
|
+
throw new Error("API URL may use http only for an exact loopback authority.");
|
|
2489
|
+
}
|
|
2490
|
+
if (url.search || url.hash) {
|
|
2491
|
+
throw new Error("API URL must not include a query string or fragment.");
|
|
2492
|
+
}
|
|
2017
2493
|
let path = url.pathname.replace(/\/+$/, "");
|
|
2018
2494
|
if (path.endsWith("/v1"))
|
|
2019
2495
|
path = path.slice(0, -"/v1".length);
|
|
2020
2496
|
url.pathname = `${path}/v1`;
|
|
2021
|
-
url.search = "";
|
|
2022
|
-
url.hash = "";
|
|
2023
2497
|
return url.toString().replace(/\/+$/, "");
|
|
2024
2498
|
}
|
|
2025
|
-
function
|
|
2026
|
-
const keys =
|
|
2027
|
-
|
|
2028
|
-
const
|
|
2029
|
-
const
|
|
2499
|
+
function resolveClientTransport(name, env = process.env, options = {}) {
|
|
2500
|
+
const keys = clientTransportEnvKeys(name);
|
|
2501
|
+
const envUrlHit = firstEnv(env, keys.apiUrlKeys, { preserveRaw: true });
|
|
2502
|
+
const explicitLocalKey = envUrlHit ? null : firstEnvDefinedKey(env, keys.apiUrlKeys);
|
|
2503
|
+
const diskUrlHit = envUrlHit || explicitLocalKey ? null : appConfigDiskValue(name, env, keys.apiUrlKeys);
|
|
2504
|
+
const urlHit = envUrlHit ?? (diskUrlHit ? { key: diskUrlHit.path, value: diskUrlHit.value } : null);
|
|
2030
2505
|
const keyHit = firstEnv(env, keys.apiKeyKeys);
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2506
|
+
const warnings = [];
|
|
2507
|
+
if (!urlHit) {
|
|
2508
|
+
if (explicitLocalKey) {
|
|
2509
|
+
const overriddenPointer = appConfigDiskValue(name, env, keys.apiUrlKeys);
|
|
2510
|
+
if (overriddenPointer) {
|
|
2511
|
+
warnings.push(`${explicitLocalKey} is defined but blank, which selects the local store. ` + `The server URL in ${overriddenPointer.path} was NOT selected: an explicit blank wins over a disk pointer.`);
|
|
2512
|
+
}
|
|
2513
|
+
return {
|
|
2514
|
+
transport: "sqlite",
|
|
2515
|
+
transportSource: explicitLocalKey,
|
|
2516
|
+
baseUrl: null,
|
|
2517
|
+
apiUrlSource: null,
|
|
2518
|
+
apiKeyPresent: Boolean(keyHit),
|
|
2519
|
+
apiKeySource: keyHit ? keyHit.key : null,
|
|
2520
|
+
apiKeyTier: null,
|
|
2521
|
+
misconfigured: false,
|
|
2522
|
+
warning: warnings.length > 0 ? warnings.join(" ") : null
|
|
2523
|
+
};
|
|
2524
|
+
}
|
|
2525
|
+
return {
|
|
2526
|
+
transport: "sqlite",
|
|
2527
|
+
transportSource: "default",
|
|
2528
|
+
baseUrl: null,
|
|
2529
|
+
apiUrlSource: null,
|
|
2530
|
+
apiKeyPresent: Boolean(keyHit),
|
|
2531
|
+
apiKeySource: keyHit ? keyHit.key : null,
|
|
2532
|
+
apiKeyTier: null,
|
|
2533
|
+
misconfigured: false,
|
|
2534
|
+
warning: null
|
|
2535
|
+
};
|
|
2039
2536
|
}
|
|
2040
|
-
if (
|
|
2041
|
-
|
|
2537
|
+
if (diskUrlHit) {
|
|
2538
|
+
warnings.push(`No ${keys.apiUrlKeys[0]} in the environment; the server URL in ${diskUrlHit.path} was used, so this client connects to the server. ` + `Unset the pointer or remove the file to stay on the local store.`);
|
|
2042
2539
|
}
|
|
2043
|
-
|
|
2540
|
+
const credential = resolveCredential(name, env, options.credentials);
|
|
2541
|
+
if (!credential) {
|
|
2542
|
+
const diskHint = credentialDiskSourcesForMessage(name, env);
|
|
2543
|
+
warnings.push(`${urlHit.key} selects the HTTP server for '${name}', but no API key could be resolved; ` + `refusing to route and leaving the local sqlite store selected. ` + `Looked for a credential file at ${diskHint}, then for ${keys.apiKeyKeys[0]} in the environment.`);
|
|
2044
2544
|
return {
|
|
2045
2545
|
transport: "sqlite",
|
|
2046
|
-
|
|
2047
|
-
modeSource,
|
|
2546
|
+
transportSource: urlHit.key,
|
|
2048
2547
|
baseUrl: null,
|
|
2548
|
+
apiUrlSource: urlHit.key,
|
|
2049
2549
|
apiKeyPresent: false,
|
|
2550
|
+
apiKeySource: null,
|
|
2551
|
+
apiKeyTier: null,
|
|
2050
2552
|
misconfigured: true,
|
|
2051
|
-
warning:
|
|
2553
|
+
warning: warnings.join(" ")
|
|
2052
2554
|
};
|
|
2053
2555
|
}
|
|
2054
|
-
|
|
2556
|
+
if (credential.warning)
|
|
2557
|
+
warnings.push(credential.warning);
|
|
2558
|
+
const apiUrlSource = urlHit.key;
|
|
2055
2559
|
let baseUrl;
|
|
2056
2560
|
try {
|
|
2057
|
-
baseUrl = toV1BaseUrl(
|
|
2561
|
+
baseUrl = toV1BaseUrl(urlHit.value);
|
|
2058
2562
|
} catch (error) {
|
|
2059
2563
|
const message = error instanceof Error ? error.message : String(error);
|
|
2060
|
-
|
|
2564
|
+
warnings.push(`Invalid API URL from ${apiUrlSource}: ${message}. Using local store.`);
|
|
2565
|
+
return {
|
|
2566
|
+
transport: "sqlite",
|
|
2567
|
+
transportSource: urlHit.key,
|
|
2568
|
+
baseUrl: null,
|
|
2569
|
+
apiUrlSource: urlHit.key,
|
|
2570
|
+
apiKeyPresent: true,
|
|
2571
|
+
apiKeySource: credential.source,
|
|
2572
|
+
apiKeyTier: credential.tier,
|
|
2573
|
+
misconfigured: true,
|
|
2574
|
+
warning: warnings.join(" ")
|
|
2575
|
+
};
|
|
2061
2576
|
}
|
|
2062
|
-
return {
|
|
2577
|
+
return {
|
|
2578
|
+
transport: "http",
|
|
2579
|
+
transportSource: urlHit.key,
|
|
2580
|
+
baseUrl,
|
|
2581
|
+
apiUrlSource,
|
|
2582
|
+
apiKeyPresent: true,
|
|
2583
|
+
apiKeySource: credential.source,
|
|
2584
|
+
apiKeyTier: credential.tier,
|
|
2585
|
+
misconfigured: false,
|
|
2586
|
+
warning: warnings.length > 0 ? warnings.join(" ") : null
|
|
2587
|
+
};
|
|
2588
|
+
}
|
|
2589
|
+
function credentialDiskSourcesForMessage(name, env) {
|
|
2590
|
+
const paths = credentialDiskSources(name, env);
|
|
2591
|
+
return paths.length > 0 ? paths.join(" or ") : "<no HOME set in this environment, so no credential file was consulted>";
|
|
2063
2592
|
}
|
|
2064
2593
|
|
|
2065
2594
|
class HasnaHttpError extends Error {
|
|
@@ -2067,49 +2596,113 @@ class HasnaHttpError extends Error {
|
|
|
2067
2596
|
method;
|
|
2068
2597
|
path;
|
|
2069
2598
|
body;
|
|
2070
|
-
|
|
2071
|
-
|
|
2599
|
+
credentialSource;
|
|
2600
|
+
credentialTier;
|
|
2601
|
+
constructor(method, path, status, body, credential) {
|
|
2602
|
+
const guidance = credential ? `. ${credential.guidance}` : "";
|
|
2603
|
+
super(`Hasna cloud request failed: ${method} ${path} -> ${status}${guidance}`);
|
|
2072
2604
|
this.name = "HasnaHttpError";
|
|
2073
2605
|
this.status = status;
|
|
2074
2606
|
this.method = method;
|
|
2075
2607
|
this.path = path;
|
|
2076
2608
|
this.body = body;
|
|
2609
|
+
this.credentialSource = credential?.source ?? null;
|
|
2610
|
+
this.credentialTier = credential?.tier ?? null;
|
|
2611
|
+
}
|
|
2612
|
+
}
|
|
2613
|
+
function currentCredential(name, apiKey) {
|
|
2614
|
+
if (typeof apiKey === "function") {
|
|
2615
|
+
return validateAndSealResolvedCredential(name, apiKey());
|
|
2616
|
+
}
|
|
2617
|
+
return explicitCredential(name, apiKey);
|
|
2618
|
+
}
|
|
2619
|
+
function authFailureGuidance(credential) {
|
|
2620
|
+
const origin = `The API key for this request came from ${credential.source}`;
|
|
2621
|
+
if (credential.deliberate) {
|
|
2622
|
+
const remedy = credential.source === CALLER_SUPPLIED_CREDENTIAL_PROVIDER_SOURCE ? `Fix that provider so it returns the current key, or replace it with resolveCredential() ` + `so diagnostics can name the original source.` : `Rotate that key, or unset the override to use the credential on disk.`;
|
|
2623
|
+
return `${origin} \u2014 a credential you selected deliberately. It was NOT substituted with any other key: ` + `falling back here would authenticate as a different principal than the one you named, which is ` + `exactly the failure an override exists to prevent. ${remedy}`;
|
|
2624
|
+
}
|
|
2625
|
+
if (credential.deprecated) {
|
|
2626
|
+
const target = credential.diskCandidates[0];
|
|
2627
|
+
const remedy = target ? `Write the CURRENT key to ${target} \u2014 that file is re-read on every call, so rotations take ` + `effect immediately and in every shell. Do not simply unset ${credential.source}: nothing was ` + `found on disk, so that would leave this client with no credential at all.` : `This environment has no HOME, so no credential file could be consulted; the disk tier is ` + `unavailable here and there is nothing to fall back to. Set HOME, or supply the key explicitly.`;
|
|
2628
|
+
return `${origin}, a variable in this process's environment \u2014 which is a snapshot taken when the process ` + `started. A STALE SHELL is the most common cause of this error: this shell exported the key before ` + `it was rotated, and will keep sending the old one until it exits. ${remedy}`;
|
|
2629
|
+
}
|
|
2630
|
+
return `${origin}, which was re-read from disk on this very call \u2014 so a stale shell is NOT the cause here. ` + `The stored credential is genuinely being rejected: rotate it, or re-run the fleet key distribution ` + `so this machine gets the current key.`;
|
|
2631
|
+
}
|
|
2632
|
+
var DEFAULT_RETRY_STATUSES = [408, 425, 429, 500, 502, 503, 504];
|
|
2633
|
+
var IDEMPOTENT_METHODS = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]);
|
|
2634
|
+
var AUTHORITY_OVERRIDE_HEADERS = new Set([
|
|
2635
|
+
"host",
|
|
2636
|
+
":authority",
|
|
2637
|
+
"forwarded",
|
|
2638
|
+
"x-forwarded-host",
|
|
2639
|
+
"x-original-host"
|
|
2640
|
+
]);
|
|
2641
|
+
function assertNoAuthorityOverrideHeaders(headers, source) {
|
|
2642
|
+
if (!headers)
|
|
2643
|
+
return;
|
|
2644
|
+
const forbidden = Object.keys(headers).find((name) => AUTHORITY_OVERRIDE_HEADERS.has(name.trim().toLowerCase()));
|
|
2645
|
+
if (forbidden) {
|
|
2646
|
+
throw new Error(`Authenticated ${source} headers must not set authority header '${forbidden}'.`);
|
|
2077
2647
|
}
|
|
2078
2648
|
}
|
|
2079
2649
|
function appendQuery(path, query) {
|
|
2080
2650
|
if (!query)
|
|
2081
2651
|
return path;
|
|
2082
|
-
const params = new URLSearchParams;
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2652
|
+
const params = query instanceof URLSearchParams ? query : new URLSearchParams;
|
|
2653
|
+
if (!(query instanceof URLSearchParams)) {
|
|
2654
|
+
for (const [key, value] of Object.entries(query)) {
|
|
2655
|
+
if (value === null || value === undefined)
|
|
2656
|
+
continue;
|
|
2657
|
+
if (Array.isArray(value)) {
|
|
2658
|
+
for (const v of value)
|
|
2659
|
+
params.append(key, String(v));
|
|
2660
|
+
} else {
|
|
2661
|
+
params.append(key, String(value));
|
|
2662
|
+
}
|
|
2663
|
+
}
|
|
2091
2664
|
}
|
|
2092
2665
|
const qs = params.toString();
|
|
2093
|
-
|
|
2666
|
+
if (!qs)
|
|
2667
|
+
return path;
|
|
2668
|
+
return `${path}${path.includes("?") ? "&" : "?"}${qs}`;
|
|
2094
2669
|
}
|
|
2095
|
-
var
|
|
2096
|
-
|
|
2097
|
-
var defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
2098
|
-
function createHttpTransport(options) {
|
|
2670
|
+
var defaultSleep = (ms) => new Promise((resolve3) => setTimeout(resolve3, ms));
|
|
2671
|
+
function createHasnaHttpTransport(options) {
|
|
2099
2672
|
const fetchImpl = options.fetchImpl ?? ((input, init) => fetch(input, init));
|
|
2100
|
-
const base = options.baseUrl
|
|
2673
|
+
const base = toV1BaseUrl(options.baseUrl);
|
|
2101
2674
|
const timeoutMs = options.timeoutMs ?? 30000;
|
|
2102
2675
|
const sleep = options.sleepImpl ?? defaultSleep;
|
|
2103
|
-
|
|
2676
|
+
const defaultRetry = options.retry;
|
|
2677
|
+
function resolveRetry(callRetry) {
|
|
2678
|
+
const chosen = callRetry !== undefined ? callRetry : defaultRetry;
|
|
2679
|
+
if (chosen === false)
|
|
2680
|
+
return null;
|
|
2681
|
+
const r = chosen ?? {};
|
|
2682
|
+
return {
|
|
2683
|
+
retries: r.retries ?? 2,
|
|
2684
|
+
baseDelayMs: r.baseDelayMs ?? 200,
|
|
2685
|
+
maxDelayMs: r.maxDelayMs ?? 2000,
|
|
2686
|
+
retryStatuses: r.retryStatuses ?? [...DEFAULT_RETRY_STATUSES]
|
|
2687
|
+
};
|
|
2688
|
+
}
|
|
2689
|
+
async function once(method, rel, url, body, opts, credential) {
|
|
2690
|
+
assertNoAuthorityOverrideHeaders(options.headers, "transport");
|
|
2691
|
+
assertNoAuthorityOverrideHeaders(opts.headers, "request");
|
|
2104
2692
|
const headers = {
|
|
2105
|
-
"x-api-key":
|
|
2106
|
-
Authorization: `Bearer ${
|
|
2693
|
+
"x-api-key": credential.apiKey,
|
|
2694
|
+
Authorization: `Bearer ${credential.apiKey}`,
|
|
2107
2695
|
Accept: "application/json",
|
|
2696
|
+
...options.headers ?? {},
|
|
2108
2697
|
...opts.headers ?? {}
|
|
2109
2698
|
};
|
|
2110
2699
|
if (opts.idempotencyKey)
|
|
2111
2700
|
headers["Idempotency-Key"] = opts.idempotencyKey;
|
|
2112
|
-
const init = {
|
|
2701
|
+
const init = {
|
|
2702
|
+
method,
|
|
2703
|
+
headers,
|
|
2704
|
+
redirect: "manual"
|
|
2705
|
+
};
|
|
2113
2706
|
if (body !== undefined) {
|
|
2114
2707
|
headers["Content-Type"] = "application/json";
|
|
2115
2708
|
init.body = JSON.stringify(body);
|
|
@@ -2147,7 +2740,27 @@ function createHttpTransport(options) {
|
|
|
2147
2740
|
}
|
|
2148
2741
|
}
|
|
2149
2742
|
if (!response.ok) {
|
|
2150
|
-
|
|
2743
|
+
if (response.status >= 300 && response.status < 400) {
|
|
2744
|
+
return {
|
|
2745
|
+
ok: false,
|
|
2746
|
+
retryable: false,
|
|
2747
|
+
error: new HasnaHttpError(method, rel, response.status, parsed)
|
|
2748
|
+
};
|
|
2749
|
+
}
|
|
2750
|
+
if (response.status === 401 || response.status === 403) {
|
|
2751
|
+
return {
|
|
2752
|
+
ok: false,
|
|
2753
|
+
retryable: false,
|
|
2754
|
+
error: new HasnaHttpError(method, rel, response.status, parsed, {
|
|
2755
|
+
source: credential.source,
|
|
2756
|
+
tier: credential.tier,
|
|
2757
|
+
guidance: authFailureGuidance(credential)
|
|
2758
|
+
})
|
|
2759
|
+
};
|
|
2760
|
+
}
|
|
2761
|
+
const retry = resolveRetry(opts.retry);
|
|
2762
|
+
const retryable = retry ? retry.retryStatuses.includes(response.status) : false;
|
|
2763
|
+
return { ok: false, retryable, error: new HasnaHttpError(method, rel, response.status, parsed) };
|
|
2151
2764
|
}
|
|
2152
2765
|
return { ok: true, value: parsed };
|
|
2153
2766
|
}
|
|
@@ -2155,24 +2768,23 @@ function createHttpTransport(options) {
|
|
|
2155
2768
|
const upper = method.toUpperCase();
|
|
2156
2769
|
const rel = appendQuery(path.startsWith("/") ? path : `/${path}`, opts.query);
|
|
2157
2770
|
const url = `${base}${rel}`;
|
|
2158
|
-
const
|
|
2159
|
-
const
|
|
2160
|
-
const maxAttempts = methodRetryable ?
|
|
2771
|
+
const retry = resolveRetry(opts.retry);
|
|
2772
|
+
const methodRetryable = IDEMPOTENT_METHODS.has(upper) || Boolean(opts.idempotencyKey);
|
|
2773
|
+
const maxAttempts = retry && methodRetryable ? retry.retries + 1 : 1;
|
|
2774
|
+
const credential = currentCredential(options.name, options.apiKey);
|
|
2161
2775
|
let last = null;
|
|
2162
2776
|
for (let attempt = 1;attempt <= maxAttempts; attempt++) {
|
|
2163
|
-
const result = await once(upper, rel, url, body, opts);
|
|
2777
|
+
const result = await once(upper, rel, url, body, opts, credential);
|
|
2164
2778
|
if (result.ok)
|
|
2165
2779
|
return result.value;
|
|
2166
2780
|
last = result;
|
|
2167
|
-
const canRetry = methodRetryable && result.retryable && attempt < maxAttempts;
|
|
2781
|
+
const canRetry = retry !== null && methodRetryable && result.retryable && attempt < maxAttempts;
|
|
2168
2782
|
if (!canRetry)
|
|
2169
2783
|
break;
|
|
2170
|
-
const backoff = Math.min(
|
|
2784
|
+
const backoff = Math.min(retry.maxDelayMs, retry.baseDelayMs * 2 ** (attempt - 1));
|
|
2171
2785
|
const jitter = Math.floor(Math.random() * (backoff / 2 + 1));
|
|
2172
2786
|
await sleep(backoff + jitter);
|
|
2173
2787
|
}
|
|
2174
|
-
if (last === null)
|
|
2175
|
-
throw new Error(`Request to ${rel} completed without a result`);
|
|
2176
2788
|
throw last.error;
|
|
2177
2789
|
}
|
|
2178
2790
|
return {
|
|
@@ -2180,81 +2792,289 @@ function createHttpTransport(options) {
|
|
|
2180
2792
|
request,
|
|
2181
2793
|
get: (path, opts) => request("GET", path, undefined, opts),
|
|
2182
2794
|
post: (path, body, opts) => request("POST", path, body, opts),
|
|
2183
|
-
patch: (path, body, opts) => request("PATCH", path, body, opts),
|
|
2184
2795
|
put: (path, body, opts) => request("PUT", path, body, opts),
|
|
2796
|
+
patch: (path, body, opts) => request("PATCH", path, body, opts),
|
|
2185
2797
|
del: (path, body, opts) => request("DELETE", path, body, opts)
|
|
2186
2798
|
};
|
|
2187
2799
|
}
|
|
2800
|
+
function createClientTransport(name, env = process.env, overrides) {
|
|
2801
|
+
const credentialOptions = overrides?.credentials;
|
|
2802
|
+
const resolution = resolveClientTransport(name, env, { ...credentialOptions ? { credentials: credentialOptions } : {} });
|
|
2803
|
+
if (resolution.misconfigured) {
|
|
2804
|
+
throw new Error(resolution.warning ?? `Client for '${name}' is misconfigured for the API client.`);
|
|
2805
|
+
}
|
|
2806
|
+
if (resolution.transport === "sqlite" || !resolution.baseUrl) {
|
|
2807
|
+
return { transport: "sqlite", client: null, resolution };
|
|
2808
|
+
}
|
|
2809
|
+
const credentialProvider = () => {
|
|
2810
|
+
const resolved = resolveCredential(name, env, credentialOptions);
|
|
2811
|
+
if (!resolved) {
|
|
2812
|
+
throw new Error(`Client for '${name}' resolved to the http transport but no API key is available any more. ` + `Looked at ${credentialDiskSourcesForMessage(name, env)}, then the environment. ` + `A credential file that was removed after this client was built is the usual cause.`);
|
|
2813
|
+
}
|
|
2814
|
+
return resolved;
|
|
2815
|
+
};
|
|
2816
|
+
return {
|
|
2817
|
+
transport: "http",
|
|
2818
|
+
client: createHasnaHttpTransport({
|
|
2819
|
+
name,
|
|
2820
|
+
baseUrl: resolution.baseUrl,
|
|
2821
|
+
apiKey: credentialProvider,
|
|
2822
|
+
...overrides?.fetchImpl ? { fetchImpl: overrides.fetchImpl } : {},
|
|
2823
|
+
...overrides?.headers ? { headers: overrides.headers } : {},
|
|
2824
|
+
...overrides?.timeoutMs ? { timeoutMs: overrides.timeoutMs } : {},
|
|
2825
|
+
...overrides?.retry !== undefined ? { retry: overrides.retry } : {},
|
|
2826
|
+
...overrides?.sleepImpl ? { sleepImpl: overrides.sleepImpl } : {}
|
|
2827
|
+
}),
|
|
2828
|
+
resolution
|
|
2829
|
+
};
|
|
2830
|
+
}
|
|
2831
|
+
|
|
2832
|
+
// ../contracts/dist/client/storage.js
|
|
2833
|
+
var MAX_CREDENTIAL_FILE_BYTES2 = 64 * 1024;
|
|
2834
|
+
var INSPECT_CUSTOM2 = Symbol.for("nodejs.util.inspect.custom");
|
|
2835
|
+
var CREDENTIAL_SEAL2 = Symbol.for("hasna:contracts:sealedCredential");
|
|
2836
|
+
var DEPRECATION_REGISTRY2 = Symbol.for("hasna:contracts:credentialDeprecationNotices");
|
|
2837
|
+
var IDEMPOTENT_METHODS2 = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]);
|
|
2838
|
+
var AUTHORITY_OVERRIDE_HEADERS2 = new Set([
|
|
2839
|
+
"host",
|
|
2840
|
+
":authority",
|
|
2841
|
+
"forwarded",
|
|
2842
|
+
"x-forwarded-host",
|
|
2843
|
+
"x-original-host"
|
|
2844
|
+
]);
|
|
2845
|
+
function resourcePath(resource) {
|
|
2846
|
+
const trimmed = resource.replace(/^\/+|\/+$/g, "");
|
|
2847
|
+
if (!trimmed)
|
|
2848
|
+
throw new Error("resource must be a non-empty path segment");
|
|
2849
|
+
return `/${trimmed}`;
|
|
2850
|
+
}
|
|
2851
|
+
function entityPath(resource, id) {
|
|
2852
|
+
if (id === undefined || id === null || `${id}`.length === 0) {
|
|
2853
|
+
throw new Error("id must be a non-empty string");
|
|
2854
|
+
}
|
|
2855
|
+
return `${resourcePath(resource)}/${encodeURIComponent(String(id))}`;
|
|
2856
|
+
}
|
|
2188
2857
|
function newIdempotencyKey() {
|
|
2189
2858
|
const g = globalThis;
|
|
2190
2859
|
if (g.crypto?.randomUUID)
|
|
2191
2860
|
return g.crypto.randomUUID();
|
|
2192
2861
|
return `idmp_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 12)}`;
|
|
2193
2862
|
}
|
|
2194
|
-
function extractItems(raw
|
|
2863
|
+
function extractItems(raw) {
|
|
2195
2864
|
if (Array.isArray(raw))
|
|
2196
2865
|
return raw;
|
|
2197
2866
|
if (raw && typeof raw === "object") {
|
|
2198
2867
|
const obj = raw;
|
|
2199
|
-
for (const key of [
|
|
2868
|
+
for (const key of ["items", "data", "results", "rows", "records"]) {
|
|
2200
2869
|
if (Array.isArray(obj[key]))
|
|
2201
2870
|
return obj[key];
|
|
2202
2871
|
}
|
|
2203
2872
|
}
|
|
2204
2873
|
return [];
|
|
2205
2874
|
}
|
|
2206
|
-
function
|
|
2207
|
-
|
|
2208
|
-
|
|
2875
|
+
function extractTotal(raw) {
|
|
2876
|
+
if (raw && typeof raw === "object") {
|
|
2877
|
+
const obj = raw;
|
|
2878
|
+
for (const key of ["total", "count", "totalCount", "total_count"]) {
|
|
2879
|
+
if (typeof obj[key] === "number")
|
|
2880
|
+
return obj[key];
|
|
2881
|
+
}
|
|
2882
|
+
}
|
|
2883
|
+
return null;
|
|
2884
|
+
}
|
|
2885
|
+
function extractCursor(raw) {
|
|
2886
|
+
if (raw && typeof raw === "object") {
|
|
2887
|
+
const obj = raw;
|
|
2888
|
+
for (const key of ["cursor", "nextCursor", "next_cursor", "next"]) {
|
|
2889
|
+
if (typeof obj[key] === "string")
|
|
2890
|
+
return obj[key];
|
|
2891
|
+
}
|
|
2892
|
+
}
|
|
2893
|
+
return null;
|
|
2894
|
+
}
|
|
2895
|
+
function isNotFoundHttpError(error) {
|
|
2896
|
+
return typeof error === "object" && error !== null && error.name === "HasnaHttpError" && error.status === 404;
|
|
2897
|
+
}
|
|
2898
|
+
function createHasnaStorageClient(name, transport) {
|
|
2209
2899
|
return {
|
|
2210
2900
|
name,
|
|
2211
2901
|
baseUrl: transport.baseUrl,
|
|
2212
2902
|
transport,
|
|
2213
|
-
async list(resource,
|
|
2214
|
-
const raw = await transport.get(
|
|
2215
|
-
return {
|
|
2903
|
+
async list(resource, options = {}) {
|
|
2904
|
+
const raw = await transport.get(resourcePath(resource), options);
|
|
2905
|
+
return {
|
|
2906
|
+
items: extractItems(raw),
|
|
2907
|
+
total: extractTotal(raw),
|
|
2908
|
+
cursor: extractCursor(raw),
|
|
2909
|
+
raw
|
|
2910
|
+
};
|
|
2216
2911
|
},
|
|
2217
|
-
async get(resource, id) {
|
|
2912
|
+
async get(resource, id, options = {}) {
|
|
2218
2913
|
try {
|
|
2219
|
-
return await transport.get(
|
|
2914
|
+
return await transport.get(entityPath(resource, id), options);
|
|
2220
2915
|
} catch (error) {
|
|
2221
|
-
if (error
|
|
2916
|
+
if (isNotFoundHttpError(error))
|
|
2222
2917
|
return null;
|
|
2223
2918
|
throw error;
|
|
2224
2919
|
}
|
|
2225
2920
|
},
|
|
2226
|
-
async create(resource, body,
|
|
2227
|
-
|
|
2921
|
+
async create(resource, body, options = {}) {
|
|
2922
|
+
const { idempotencyKey, ...rest } = options;
|
|
2923
|
+
return transport.post(resourcePath(resource), body, {
|
|
2924
|
+
...rest,
|
|
2925
|
+
idempotencyKey: idempotencyKey ?? newIdempotencyKey()
|
|
2926
|
+
});
|
|
2228
2927
|
},
|
|
2229
|
-
async update(resource, id, patch,
|
|
2928
|
+
async update(resource, id, patch, options = {}) {
|
|
2929
|
+
const { method = "PATCH", idempotencyKey, ...rest } = options;
|
|
2230
2930
|
const call = method === "PUT" ? transport.put : transport.patch;
|
|
2231
|
-
return call(
|
|
2931
|
+
return call(entityPath(resource, id), patch, { ...rest, ...idempotencyKey ? { idempotencyKey } : {} });
|
|
2232
2932
|
},
|
|
2233
|
-
async delete(resource, id) {
|
|
2933
|
+
async delete(resource, id, options = {}) {
|
|
2234
2934
|
try {
|
|
2235
|
-
await transport.del(
|
|
2935
|
+
await transport.del(entityPath(resource, id), undefined, options);
|
|
2236
2936
|
} catch (error) {
|
|
2237
|
-
if (error
|
|
2937
|
+
if (isNotFoundHttpError(error))
|
|
2238
2938
|
return;
|
|
2239
2939
|
throw error;
|
|
2240
2940
|
}
|
|
2241
2941
|
}
|
|
2242
2942
|
};
|
|
2243
2943
|
}
|
|
2244
|
-
|
|
2944
|
+
|
|
2945
|
+
// src/http/client.ts
|
|
2946
|
+
function envToken2(name) {
|
|
2947
|
+
return name.toUpperCase().replace(/-/g, "_");
|
|
2948
|
+
}
|
|
2949
|
+
function envKeys(name) {
|
|
2950
|
+
const token = envToken2(name);
|
|
2951
|
+
return {
|
|
2952
|
+
storeKeys: [`HASNA_${token}_CLIENT_STORE`, `${token}_CLIENT_STORE`],
|
|
2953
|
+
apiUrlKeys: [`HASNA_${token}_API_URL`],
|
|
2954
|
+
apiKeyKeys: [`HASNA_${token}_API_KEY`]
|
|
2955
|
+
};
|
|
2956
|
+
}
|
|
2957
|
+
function normalizeClientStore(value) {
|
|
2958
|
+
const normalized = value.trim().toLowerCase();
|
|
2959
|
+
if (normalized === "sqlite")
|
|
2960
|
+
return "sqlite";
|
|
2961
|
+
if (normalized === "http" || normalized === "https")
|
|
2962
|
+
return "http";
|
|
2963
|
+
throw new Error(`Unknown client store: ${value}. Use sqlite or http.`);
|
|
2964
|
+
}
|
|
2965
|
+
function firstEnv2(env, keys) {
|
|
2966
|
+
for (const key of keys) {
|
|
2967
|
+
const value = env[key]?.trim();
|
|
2968
|
+
if (value)
|
|
2969
|
+
return { key, value };
|
|
2970
|
+
}
|
|
2971
|
+
return null;
|
|
2972
|
+
}
|
|
2973
|
+
function toV1BaseUrl2(apiUrl) {
|
|
2974
|
+
const url = new URL(apiUrl);
|
|
2975
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
2976
|
+
throw new Error("API URL must use http or https.");
|
|
2977
|
+
}
|
|
2978
|
+
let path = url.pathname.replace(/\/+$/, "");
|
|
2979
|
+
if (path.endsWith("/v1"))
|
|
2980
|
+
path = path.slice(0, -"/v1".length);
|
|
2981
|
+
url.pathname = `${path}/v1`;
|
|
2982
|
+
url.search = "";
|
|
2983
|
+
url.hash = "";
|
|
2984
|
+
return url.toString().replace(/\/+$/, "");
|
|
2985
|
+
}
|
|
2986
|
+
function resolveTransport(name, env = process.env) {
|
|
2987
|
+
const keys = envKeys(name);
|
|
2988
|
+
const storeHit = firstEnv2(env, keys.storeKeys);
|
|
2989
|
+
const urlHit = firstEnv2(env, keys.apiUrlKeys);
|
|
2990
|
+
const keyHit = firstEnv2(env, keys.apiKeyKeys);
|
|
2991
|
+
let requested = "sqlite";
|
|
2992
|
+
let modeSource = "default";
|
|
2993
|
+
if (storeHit) {
|
|
2994
|
+
requested = normalizeClientStore(storeHit.value);
|
|
2995
|
+
modeSource = storeHit.key;
|
|
2996
|
+
} else if (urlHit && keyHit) {
|
|
2997
|
+
requested = "http";
|
|
2998
|
+
modeSource = "auto:api-url+api-key";
|
|
2999
|
+
} else if (urlHit || keyHit) {
|
|
3000
|
+
const missing = urlHit ? keys.apiKeyKeys[0] : keys.apiUrlKeys[0];
|
|
3001
|
+
const present = urlHit ? keys.apiUrlKeys[0] : keys.apiKeyKeys[0];
|
|
3002
|
+
return {
|
|
3003
|
+
transport: "sqlite",
|
|
3004
|
+
requested,
|
|
3005
|
+
modeSource,
|
|
3006
|
+
baseUrl: null,
|
|
3007
|
+
apiKeyPresent: Boolean(keyHit),
|
|
3008
|
+
misconfigured: true,
|
|
3009
|
+
warning: `${present} is set but ${missing} is not: the hosted API is only ` + `selected when BOTH are present. Set ${missing}, or unset ${present} to ` + `use the on-box store.`
|
|
3010
|
+
};
|
|
3011
|
+
}
|
|
3012
|
+
if (requested === "sqlite") {
|
|
3013
|
+
return { transport: "sqlite", requested, modeSource, baseUrl: null, apiKeyPresent: Boolean(keyHit), misconfigured: false, warning: null };
|
|
3014
|
+
}
|
|
3015
|
+
if (!urlHit) {
|
|
3016
|
+
return {
|
|
3017
|
+
transport: "sqlite",
|
|
3018
|
+
requested,
|
|
3019
|
+
modeSource,
|
|
3020
|
+
baseUrl: null,
|
|
3021
|
+
apiKeyPresent: Boolean(keyHit),
|
|
3022
|
+
misconfigured: true,
|
|
3023
|
+
warning: `${modeSource}=http but no API URL is set (${keys.apiUrlKeys[0]}). Refusing to route to the API.`
|
|
3024
|
+
};
|
|
3025
|
+
}
|
|
3026
|
+
if (!keyHit) {
|
|
3027
|
+
return {
|
|
3028
|
+
transport: "sqlite",
|
|
3029
|
+
requested,
|
|
3030
|
+
modeSource,
|
|
3031
|
+
baseUrl: null,
|
|
3032
|
+
apiKeyPresent: false,
|
|
3033
|
+
misconfigured: true,
|
|
3034
|
+
warning: `${modeSource}=http but no API key is set (${keys.apiKeyKeys[0]}). Refusing to route to the API.`
|
|
3035
|
+
};
|
|
3036
|
+
}
|
|
3037
|
+
const rawUrl = urlHit.value;
|
|
3038
|
+
let baseUrl;
|
|
3039
|
+
try {
|
|
3040
|
+
baseUrl = toV1BaseUrl2(rawUrl);
|
|
3041
|
+
} catch (error) {
|
|
3042
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
3043
|
+
return { transport: "sqlite", requested, modeSource, baseUrl: null, apiKeyPresent: true, misconfigured: true, warning: `Invalid API URL: ${message}.` };
|
|
3044
|
+
}
|
|
3045
|
+
return { transport: "http", requested, modeSource, baseUrl, apiKeyPresent: true, misconfigured: false, warning: null };
|
|
3046
|
+
}
|
|
3047
|
+
var RETRY_STATUSES = new Set([408, 425, 429, 500, 502, 503, 504]);
|
|
3048
|
+
var IDEMPOTENT = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]);
|
|
3049
|
+
function resolveStoreClient(name, env = process.env) {
|
|
2245
3050
|
const resolution = resolveTransport(name, env);
|
|
2246
3051
|
if (resolution.misconfigured) {
|
|
3052
|
+
const wired2 = createClientTransport(name, env);
|
|
3053
|
+
if (wired2.transport === "http") {
|
|
3054
|
+
return {
|
|
3055
|
+
transport: "http",
|
|
3056
|
+
client: createHasnaStorageClient(name, wired2.client),
|
|
3057
|
+
resolution: {
|
|
3058
|
+
transport: "http",
|
|
3059
|
+
requested: "http",
|
|
3060
|
+
modeSource: resolution.modeSource === "default" ? "auto:api-url+seam-credential" : resolution.modeSource,
|
|
3061
|
+
baseUrl: wired2.resolution.baseUrl,
|
|
3062
|
+
apiKeyPresent: true,
|
|
3063
|
+
misconfigured: false,
|
|
3064
|
+
warning: null
|
|
3065
|
+
}
|
|
3066
|
+
};
|
|
3067
|
+
}
|
|
2247
3068
|
throw new Error(resolution.warning ?? `Client for '${name}' is misconfigured for the /v1 API.`);
|
|
2248
3069
|
}
|
|
2249
3070
|
if (resolution.transport === "sqlite" || !resolution.baseUrl) {
|
|
2250
3071
|
return { transport: "sqlite", client: null, resolution };
|
|
2251
3072
|
}
|
|
2252
|
-
const
|
|
2253
|
-
|
|
2254
|
-
if (!apiKey)
|
|
3073
|
+
const wired = createClientTransport(name, env);
|
|
3074
|
+
if (wired.transport !== "http") {
|
|
2255
3075
|
throw new Error(`Client for '${name}' resolved to the /v1 API without an API key.`);
|
|
2256
|
-
|
|
2257
|
-
return { transport: "http", client:
|
|
3076
|
+
}
|
|
3077
|
+
return { transport: "http", client: createHasnaStorageClient(name, wired.client), resolution };
|
|
2258
3078
|
}
|
|
2259
3079
|
|
|
2260
3080
|
// src/store.ts
|
|
@@ -2337,6 +3157,22 @@ var localStore = {
|
|
|
2337
3157
|
await withLocalStoreReaderLease(() => saveFeedback(input));
|
|
2338
3158
|
}
|
|
2339
3159
|
};
|
|
3160
|
+
async function listResource(client, resource, query) {
|
|
3161
|
+
const raw = await client.transport.get(`/${resource}`, query ? { query } : undefined);
|
|
3162
|
+
return { items: extractEnvelopeItems(raw, resource), raw };
|
|
3163
|
+
}
|
|
3164
|
+
function extractEnvelopeItems(raw, resource) {
|
|
3165
|
+
if (Array.isArray(raw))
|
|
3166
|
+
return raw;
|
|
3167
|
+
if (raw && typeof raw === "object") {
|
|
3168
|
+
const obj = raw;
|
|
3169
|
+
for (const key of [resource, "items", "data", "results", "rows", "records"]) {
|
|
3170
|
+
if (Array.isArray(obj[key]))
|
|
3171
|
+
return obj[key];
|
|
3172
|
+
}
|
|
3173
|
+
}
|
|
3174
|
+
return [];
|
|
3175
|
+
}
|
|
2340
3176
|
function apiStore(client) {
|
|
2341
3177
|
return {
|
|
2342
3178
|
mode: "http",
|
|
@@ -2344,7 +3180,7 @@ function apiStore(client) {
|
|
|
2344
3180
|
async createRecording(input, idempotencyKey) {
|
|
2345
3181
|
const keyCandidate = idempotencyKey === undefined && (input.id === undefined || input.id === null) ? randomUUID5() : idempotencyKey;
|
|
2346
3182
|
const identity = recordingCreateIdentity(input, keyCandidate, { bindIdempotencyKeyToId: false });
|
|
2347
|
-
const res = await client.create("recordings", identity.input, identity.idempotencyKey);
|
|
3183
|
+
const res = await client.create("recordings", identity.input, { idempotencyKey: identity.idempotencyKey });
|
|
2348
3184
|
return unwrap(res, "recording");
|
|
2349
3185
|
},
|
|
2350
3186
|
async getRecording(id) {
|
|
@@ -2352,7 +3188,7 @@ function apiStore(client) {
|
|
|
2352
3188
|
return res ? unwrap(res, "recording") : null;
|
|
2353
3189
|
},
|
|
2354
3190
|
async listRecordings(filter) {
|
|
2355
|
-
const { items } = await client
|
|
3191
|
+
const { items } = await listResource(client, "recordings", listQuery(filter));
|
|
2356
3192
|
return items;
|
|
2357
3193
|
},
|
|
2358
3194
|
async countRecordings(filter) {
|
|
@@ -2363,7 +3199,7 @@ function apiStore(client) {
|
|
|
2363
3199
|
const seenPageKeys = new Set;
|
|
2364
3200
|
while (pageRequests < maxPageRequests) {
|
|
2365
3201
|
pageRequests += 1;
|
|
2366
|
-
const { items, raw } = await client
|
|
3202
|
+
const { items, raw } = await listResource(client, "recordings", {
|
|
2367
3203
|
...listQuery(filter),
|
|
2368
3204
|
limit: pageLimit,
|
|
2369
3205
|
offset
|
|
@@ -2386,7 +3222,7 @@ function apiStore(client) {
|
|
|
2386
3222
|
throw new Error(`Recordings API exceeded ${maxPageRequests} pages while counting legacy results`);
|
|
2387
3223
|
},
|
|
2388
3224
|
async searchRecordings(query, filter) {
|
|
2389
|
-
const { items } = await client
|
|
3225
|
+
const { items } = await listResource(client, "recordings", listQuery({ ...filter ?? {}, search: query }));
|
|
2390
3226
|
return items;
|
|
2391
3227
|
},
|
|
2392
3228
|
async deleteRecording(id) {
|
|
@@ -2418,7 +3254,7 @@ function apiStore(client) {
|
|
|
2418
3254
|
return res ? unwrap(res, "agent") : null;
|
|
2419
3255
|
},
|
|
2420
3256
|
async listAgents() {
|
|
2421
|
-
const { items } = await client
|
|
3257
|
+
const { items } = await listResource(client, "agents");
|
|
2422
3258
|
return items;
|
|
2423
3259
|
},
|
|
2424
3260
|
async heartbeatAgent(idOrName) {
|
|
@@ -2458,7 +3294,7 @@ function apiStore(client) {
|
|
|
2458
3294
|
return res ? unwrap(res, "project") : null;
|
|
2459
3295
|
},
|
|
2460
3296
|
async listProjects() {
|
|
2461
|
-
const { items } = await client
|
|
3297
|
+
const { items } = await listResource(client, "projects");
|
|
2462
3298
|
return items;
|
|
2463
3299
|
},
|
|
2464
3300
|
async saveFeedback(input) {
|
|
@@ -2506,7 +3342,7 @@ var cached = null;
|
|
|
2506
3342
|
function getStore(env = process.env) {
|
|
2507
3343
|
if (env === process.env && cached)
|
|
2508
3344
|
return cached;
|
|
2509
|
-
const resolved =
|
|
3345
|
+
const resolved = resolveStoreClient(APP, env);
|
|
2510
3346
|
const store = resolved.transport === "http" ? apiStore(resolved.client) : localStore;
|
|
2511
3347
|
if (env === process.env)
|
|
2512
3348
|
cached = store;
|
|
@@ -2515,7 +3351,7 @@ function getStore(env = process.env) {
|
|
|
2515
3351
|
|
|
2516
3352
|
// src/lib/recorder.ts
|
|
2517
3353
|
import { spawn as spawn2 } from "child_process";
|
|
2518
|
-
import { join as
|
|
3354
|
+
import { join as join5 } from "path";
|
|
2519
3355
|
import { existsSync as existsSync3 } from "fs";
|
|
2520
3356
|
|
|
2521
3357
|
// src/types/index.ts
|
|
@@ -2566,7 +3402,7 @@ function startRecording(config) {
|
|
|
2566
3402
|
}
|
|
2567
3403
|
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
2568
3404
|
const filename = `recording-${timestamp}.${config.audio_format}`;
|
|
2569
|
-
const filepath =
|
|
3405
|
+
const filepath = join5(config.audio_dir, filename);
|
|
2570
3406
|
const args = buildRecordArgs(filepath, config);
|
|
2571
3407
|
const [command, ...commandArgs] = args;
|
|
2572
3408
|
if (command === undefined) {
|
|
@@ -2638,7 +3474,7 @@ function buildRecordArgs(filepath, config) {
|
|
|
2638
3474
|
async function recordDuration(seconds, config) {
|
|
2639
3475
|
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
2640
3476
|
const filename = `recording-${timestamp}.${config.audio_format}`;
|
|
2641
|
-
const filepath =
|
|
3477
|
+
const filepath = join5(config.audio_dir, filename);
|
|
2642
3478
|
const args = [
|
|
2643
3479
|
"rec",
|
|
2644
3480
|
"-r",
|
|
@@ -2811,8 +3647,8 @@ ${trimmed}`;
|
|
|
2811
3647
|
|
|
2812
3648
|
// src/lib/capture-probe.ts
|
|
2813
3649
|
import { spawnSync as spawnSync2 } from "child_process";
|
|
2814
|
-
import { existsSync as existsSync4, readFileSync as
|
|
2815
|
-
import { join as
|
|
3650
|
+
import { existsSync as existsSync4, readFileSync as readFileSync3, rmSync as rmSync2 } from "fs";
|
|
3651
|
+
import { join as join6 } from "path";
|
|
2816
3652
|
import { tmpdir } from "os";
|
|
2817
3653
|
|
|
2818
3654
|
// src/lib/macos-bundle.ts
|
|
@@ -2828,7 +3664,7 @@ var WAVE_FORMAT_PCM = 1;
|
|
|
2828
3664
|
var WAVE_FORMAT_EXTENSIBLE = 65534;
|
|
2829
3665
|
var SUBFORMAT_OFFSET_IN_EXTENSION = 8;
|
|
2830
3666
|
function readWavPeak(filepath) {
|
|
2831
|
-
const buf =
|
|
3667
|
+
const buf = readFileSync3(filepath);
|
|
2832
3668
|
if (buf.length < RIFF_HEADER_BYTES) {
|
|
2833
3669
|
throw new Error(`not a RIFF file (${buf.length} bytes): ${filepath}`);
|
|
2834
3670
|
}
|
|
@@ -2903,7 +3739,7 @@ function probeMicrophoneCapture(config, options = {}) {
|
|
|
2903
3739
|
peak: 0,
|
|
2904
3740
|
silent: null
|
|
2905
3741
|
};
|
|
2906
|
-
const filepath =
|
|
3742
|
+
const filepath = join6(tmpdir(), `recordings-capture-probe-${process.pid}-${Date.now()}.wav`);
|
|
2907
3743
|
try {
|
|
2908
3744
|
const result = spawnSync2(executable, [
|
|
2909
3745
|
"-q",
|
|
@@ -3003,37 +3839,37 @@ function captureProbeSubject(env = process.env, options = {}) {
|
|
|
3003
3839
|
const termProgram = env.TERM_PROGRAM?.trim();
|
|
3004
3840
|
const hasTty = options.hasTty ?? Boolean(process.stdin.isTTY || process.stdout.isTTY);
|
|
3005
3841
|
if (overSsh) {
|
|
3006
|
-
const subject2 = "the SSH session (sshd), not
|
|
3842
|
+
const subject2 = "the SSH session (sshd), not HasnaRecordings.app";
|
|
3007
3843
|
return {
|
|
3008
3844
|
headless: true,
|
|
3009
3845
|
subject_known: true,
|
|
3010
3846
|
subject: subject2,
|
|
3011
|
-
note: "Running over SSH: macOS cannot display a consent prompt to a session with no GUI, " + "so Microphone stays not_determined and a silent capture here says NOTHING about " + "whether
|
|
3847
|
+
note: "Running over SSH: macOS cannot display a consent prompt to a session with no GUI, " + "so Microphone stays not_determined and a silent capture here says NOTHING about " + "whether HasnaRecordings.app can record. Judge the app by its own TCC entry and its log."
|
|
3012
3848
|
};
|
|
3013
3849
|
}
|
|
3014
3850
|
if (inTmux) {
|
|
3015
|
-
const subject2 = "the tmux server, not
|
|
3851
|
+
const subject2 = "the tmux server, not HasnaRecordings.app and not this pane's shell";
|
|
3016
3852
|
return {
|
|
3017
3853
|
headless: false,
|
|
3018
3854
|
subject_known: true,
|
|
3019
3855
|
subject: subject2,
|
|
3020
|
-
note: "Running inside tmux: TCC attributes this capture to the tmux binary, which holds its " + "own grant. tmux also snapshots the environment at pane creation, so SSH variables may " + "be missing even in a remote session \u2014 treat a silent result as inconclusive about both " + "
|
|
3856
|
+
note: "Running inside tmux: TCC attributes this capture to the tmux binary, which holds its " + "own grant. tmux also snapshots the environment at pane creation, so SSH variables may " + "be missing even in a remote session \u2014 treat a silent result as inconclusive about both " + "HasnaRecordings.app and about whether anyone could have been prompted."
|
|
3021
3857
|
};
|
|
3022
3858
|
}
|
|
3023
3859
|
if (!termProgram && !hasTty) {
|
|
3024
3860
|
return {
|
|
3025
3861
|
headless: true,
|
|
3026
3862
|
subject_known: false,
|
|
3027
|
-
subject: "an unidentified responsible process (not
|
|
3028
|
-
note: "The responsible process could not be identified: no SSH variables, no TERM_PROGRAM and " + "no tty, which is what launchd, cron, CI and sudo look like. Whatever holds the grant, it " + "is not
|
|
3863
|
+
subject: "an unidentified responsible process (not HasnaRecordings.app)",
|
|
3864
|
+
note: "The responsible process could not be identified: no SSH variables, no TERM_PROGRAM and " + "no tty, which is what launchd, cron, CI and sudo look like. Whatever holds the grant, it " + "is not HasnaRecordings.app, and nothing here can be shown a consent prompt. Inconclusive."
|
|
3029
3865
|
};
|
|
3030
3866
|
}
|
|
3031
|
-
const subject = `${termProgram || "the terminal application running this command"}, not
|
|
3867
|
+
const subject = `${termProgram || "the terminal application running this command"}, not HasnaRecordings.app`;
|
|
3032
3868
|
return {
|
|
3033
3869
|
headless: false,
|
|
3034
3870
|
subject_known: Boolean(termProgram),
|
|
3035
3871
|
subject,
|
|
3036
|
-
note: `Grants are per responsible process: this probe exercises ${subject}. ` + "A pass proves the microphone hardware and the input device work; it does not " + "transfer to
|
|
3872
|
+
note: `Grants are per responsible process: this probe exercises ${subject}. ` + "A pass proves the microphone hardware and the input device work; it does not " + "transfer to HasnaRecordings.app, which needs its own grant."
|
|
3037
3873
|
};
|
|
3038
3874
|
}
|
|
3039
3875
|
var TCC_UNREADABLE_STATE = TCC_DATABASE_UNREADABLE_STATE;
|
|
@@ -3063,7 +3899,7 @@ function microphoneGrantInstruction(options) {
|
|
|
3063
3899
|
const bundlePath = candidates[0] ?? null;
|
|
3064
3900
|
const steps = [];
|
|
3065
3901
|
if (!bundlePath) {
|
|
3066
|
-
steps.push("No
|
|
3902
|
+
steps.push("No HasnaRecordings.app bundle was found on disk, so there is nothing to grant Microphone to yet. " + "Install the app first ('recordings app install').");
|
|
3067
3903
|
return {
|
|
3068
3904
|
bundle_path: null,
|
|
3069
3905
|
bundle_identifier: RECORDINGS_BUNDLE_IDENTIFIER,
|
|
@@ -3072,10 +3908,10 @@ function microphoneGrantInstruction(options) {
|
|
|
3072
3908
|
};
|
|
3073
3909
|
}
|
|
3074
3910
|
if (candidates.length > 1) {
|
|
3075
|
-
steps.push(`AMBIGUOUS: ${candidates.length}
|
|
3911
|
+
steps.push(`AMBIGUOUS: ${candidates.length} HasnaRecordings.app bundles exist (${candidates.join(", ")}). ` + "A TCC grant is bound to the bundle's code signature, so granting one does not grant the " + "other, and the toggle in Settings does not say which is which. Remove the bundles you are " + "not running before granting, or the grant may attach to the wrong one.");
|
|
3076
3912
|
}
|
|
3077
3913
|
steps.push(`At the keyboard on the machine itself (not over SSH), launch ${bundlePath} and start a ` + "recording once. macOS shows the consent sheet titled " + `"\u201CRecordings\u201D would like to access the microphone" \u2014 click Allow.`);
|
|
3078
|
-
steps.push("If no sheet appears, open System Settings \u2192 Privacy & Security \u2192 Microphone " + "and switch ON the row named \u201CRecordings\u201D. " + `That row is bundle ${RECORDINGS_BUNDLE_IDENTIFIER} at ${bundlePath}; the binary that ` + `receives the grant is ${
|
|
3914
|
+
steps.push("If no sheet appears, open System Settings \u2192 Privacy & Security \u2192 Microphone " + "and switch ON the row named \u201CRecordings\u201D. " + `That row is bundle ${RECORDINGS_BUNDLE_IDENTIFIER} at ${bundlePath}; the binary that ` + `receives the grant is ${join6(bundlePath, "Contents", "MacOS", "Recordings")}.`);
|
|
3079
3915
|
if (options.requestState === "never_requested") {
|
|
3080
3916
|
steps.push("Note: the app has never requested microphone access on this machine (no TCC entry exists), " + "so the Microphone list will NOT contain a \u201CRecordings\u201D row until the app asks once. " + "Do the launch-and-record step first; the Settings toggle only exists afterwards.");
|
|
3081
3917
|
} else if (options.requestState === "unknown") {
|
|
@@ -3168,7 +4004,7 @@ function describeActiveStore(config, env = process.env) {
|
|
|
3168
4004
|
warnings.push(`writes go to ${safeBaseUrl(resolution.baseUrl)}, but ${localDbPath} still holds ` + `${localDbRecordings} recordings from an earlier on-box-only period. ` + "That file is NOT the live store \u2014 auditing it undercounts and looks like data loss.");
|
|
3169
4005
|
}
|
|
3170
4006
|
if (resolution.transport === "http" && resolution.modeSource === AUTO_FLIP_MODE_SOURCE) {
|
|
3171
|
-
warnings.push("the API transport was selected by the mere PRESENCE of " + "HASNA_RECORDINGS_API_URL + HASNA_RECORDINGS_API_KEY
|
|
4007
|
+
warnings.push("the API transport was selected by the mere PRESENCE of " + "HASNA_RECORDINGS_API_URL + HASNA_RECORDINGS_API_KEY. " + "Check `launchctl getenv HASNA_RECORDINGS_API_URL` too: a launchd session variable " + "is inherited by the GUI app as well as by shells, and is invisible in a login profile.");
|
|
3172
4008
|
}
|
|
3173
4009
|
return {
|
|
3174
4010
|
transport: resolution.transport,
|
|
@@ -3608,12 +4444,12 @@ ${block}` : block;
|
|
|
3608
4444
|
|
|
3609
4445
|
// src/cli/macos-permissions.ts
|
|
3610
4446
|
import { spawnSync as spawnSync3 } from "child_process";
|
|
3611
|
-
import { existsSync as existsSync6, mkdtempSync, rmSync as rmSync3, statSync as
|
|
4447
|
+
import { existsSync as existsSync6, mkdtempSync, rmSync as rmSync3, statSync as statSync3, writeFileSync as writeFileSync2 } from "fs";
|
|
3612
4448
|
import { tmpdir as tmpdir2 } from "os";
|
|
3613
|
-
import { join as
|
|
4449
|
+
import { join as join7 } from "path";
|
|
3614
4450
|
var defaultPermissionHelperRunner = (executable, arguments_, options) => spawnSync3(executable, arguments_, options);
|
|
3615
4451
|
function runMacOSPermissionRequest(appPath, runner = defaultPermissionHelperRunner) {
|
|
3616
|
-
const executable =
|
|
4452
|
+
const executable = join7(appPath, "Contents", "MacOS", "Recordings");
|
|
3617
4453
|
const result = runner(executable, ["--request-permissions", "--open-permission-settings"], { stdio: "inherit" });
|
|
3618
4454
|
return {
|
|
3619
4455
|
exitCode: result.error ? 1 : result.status ?? 1,
|
|
@@ -3624,8 +4460,8 @@ var CODESIGN_REQUIREMENT_SATISFIED_STATUS = 0;
|
|
|
3624
4460
|
var CODESIGN_REQUIREMENT_UNSATISFIED_STATUS = 3;
|
|
3625
4461
|
function tccDatabasePaths(home) {
|
|
3626
4462
|
return [
|
|
3627
|
-
|
|
3628
|
-
|
|
4463
|
+
join7(home, "Library", "Application Support", "com.apple.TCC", "TCC.db"),
|
|
4464
|
+
join7("/", "Library", "Application Support", "com.apple.TCC", "TCC.db")
|
|
3629
4465
|
];
|
|
3630
4466
|
}
|
|
3631
4467
|
function tccAuthValueLabel(value) {
|
|
@@ -3652,8 +4488,8 @@ function verifyStoredRequirementWithCodesign(csreqHex, appPath, runner = default
|
|
|
3652
4488
|
return "unverifiable";
|
|
3653
4489
|
let scratchDirectory = null;
|
|
3654
4490
|
try {
|
|
3655
|
-
scratchDirectory = mkdtempSync(
|
|
3656
|
-
const requirementPath =
|
|
4491
|
+
scratchDirectory = mkdtempSync(join7(tmpdir2(), "recordings-tcc-csreq-"));
|
|
4492
|
+
const requirementPath = join7(scratchDirectory, "tcc-requirement.bin");
|
|
3657
4493
|
writeFileSync2(requirementPath, Buffer.from(normalized, "hex"));
|
|
3658
4494
|
const result = runner(requirementPath, appPath);
|
|
3659
4495
|
if (result.error)
|
|
@@ -3673,7 +4509,7 @@ function verifyStoredRequirementWithCodesign(csreqHex, appPath, runner = default
|
|
|
3673
4509
|
var defaultTccPermissionProbe = {
|
|
3674
4510
|
databasePresence: (dbPath) => {
|
|
3675
4511
|
try {
|
|
3676
|
-
|
|
4512
|
+
statSync3(dbPath);
|
|
3677
4513
|
return "present";
|
|
3678
4514
|
} catch (error) {
|
|
3679
4515
|
return error.code === "ENOENT" ? "absent" : "indeterminate";
|
|
@@ -3712,8 +4548,8 @@ function describeStoredRequirementWithCsreq(csreqHex, runner = (requirementPath)
|
|
|
3712
4548
|
}
|
|
3713
4549
|
let scratchDirectory = null;
|
|
3714
4550
|
try {
|
|
3715
|
-
scratchDirectory = mkdtempSync(
|
|
3716
|
-
const requirementPath =
|
|
4551
|
+
scratchDirectory = mkdtempSync(join7(tmpdir2(), "recordings-tcc-decode-"));
|
|
4552
|
+
const requirementPath = join7(scratchDirectory, "tcc-requirement.bin");
|
|
3717
4553
|
writeFileSync2(requirementPath, Buffer.from(normalized, "hex"));
|
|
3718
4554
|
const result = runner(requirementPath);
|
|
3719
4555
|
if (result.error || result.status !== 0)
|
|
@@ -4115,7 +4951,7 @@ function writeUseFnKey(enabled) {
|
|
|
4115
4951
|
}
|
|
4116
4952
|
|
|
4117
4953
|
// src/cli/trigger-probe.ts
|
|
4118
|
-
import { closeSync, openSync, readSync, statSync as
|
|
4954
|
+
import { closeSync, openSync, readSync, statSync as statSync4 } from "fs";
|
|
4119
4955
|
var FN_BLOCKING_ACCESSIBILITY_STATES = [
|
|
4120
4956
|
"denied",
|
|
4121
4957
|
"stale_allowed_for_previous_app_build"
|
|
@@ -4234,7 +5070,7 @@ function readAppLogTail(logPath, maxBytes = APP_LOG_TAIL_BYTES) {
|
|
|
4234
5070
|
return null;
|
|
4235
5071
|
let handle = null;
|
|
4236
5072
|
try {
|
|
4237
|
-
const size =
|
|
5073
|
+
const size = statSync4(logPath).size;
|
|
4238
5074
|
if (size === 0)
|
|
4239
5075
|
return null;
|
|
4240
5076
|
const length = Math.min(size, maxBytes);
|
|
@@ -4354,7 +5190,7 @@ function currentMachineId(env = process.env, hostName = hostname()) {
|
|
|
4354
5190
|
}
|
|
4355
5191
|
|
|
4356
5192
|
// src/lib/bun-runtime.ts
|
|
4357
|
-
import { accessSync, constants as fsConstants, realpathSync as realpathSync3, statSync as
|
|
5193
|
+
import { accessSync, constants as fsConstants, realpathSync as realpathSync3, statSync as statSync5 } from "fs";
|
|
4358
5194
|
import { isAbsolute as isAbsolute2 } from "path";
|
|
4359
5195
|
import { randomBytes } from "crypto";
|
|
4360
5196
|
import { spawnSync as spawnSync5 } from "child_process";
|
|
@@ -4376,7 +5212,7 @@ function validateBunExecutable(candidate) {
|
|
|
4376
5212
|
let executable;
|
|
4377
5213
|
try {
|
|
4378
5214
|
executable = realpathSync3(candidate);
|
|
4379
|
-
if (!
|
|
5215
|
+
if (!statSync5(executable).isFile())
|
|
4380
5216
|
return { reason: "resolved path is not a regular file" };
|
|
4381
5217
|
accessSync(executable, fsConstants.X_OK);
|
|
4382
5218
|
} catch {
|
|
@@ -4456,11 +5292,11 @@ import {
|
|
|
4456
5292
|
fsyncSync,
|
|
4457
5293
|
mkdtempSync as mkdtempSync2,
|
|
4458
5294
|
openSync as openSync2,
|
|
4459
|
-
readFileSync as
|
|
5295
|
+
readFileSync as readFileSync4,
|
|
4460
5296
|
rmSync as rmSync4,
|
|
4461
5297
|
writeFileSync as writeFileSync3
|
|
4462
5298
|
} from "fs";
|
|
4463
|
-
import { isAbsolute as isAbsolute3, join as
|
|
5299
|
+
import { isAbsolute as isAbsolute3, join as join8 } from "path";
|
|
4464
5300
|
var LOWER_SHA256 = /^[a-f0-9]{64}$/;
|
|
4465
5301
|
var LOWER_SOURCE_SHA = /^[a-f0-9]{40}$/;
|
|
4466
5302
|
var RELEASE_VERSION = /^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$/;
|
|
@@ -4533,7 +5369,7 @@ function prepareReleaseInstallInputs(input) {
|
|
|
4533
5369
|
if (!isAbsolute3(snapshotRoot)) {
|
|
4534
5370
|
throw new Error("release snapshot root must be absolute");
|
|
4535
5371
|
}
|
|
4536
|
-
const snapshotDirectory = mkdtempSync2(
|
|
5372
|
+
const snapshotDirectory = mkdtempSync2(join8(snapshotRoot, "recordings-release-install."));
|
|
4537
5373
|
let cleaned = false;
|
|
4538
5374
|
const cleanup = () => {
|
|
4539
5375
|
if (cleaned)
|
|
@@ -4545,9 +5381,9 @@ function prepareReleaseInstallInputs(input) {
|
|
|
4545
5381
|
rmSync4(snapshotDirectory, { recursive: true, force: true });
|
|
4546
5382
|
};
|
|
4547
5383
|
try {
|
|
4548
|
-
const manifestSnapshot =
|
|
5384
|
+
const manifestSnapshot = join8(snapshotDirectory, `${input.manifestSha256}.manifest.json`);
|
|
4549
5385
|
const envelopeDigest = createHash2("sha256").update(envelopeBytes).digest("hex");
|
|
4550
|
-
const envelopeSnapshot =
|
|
5386
|
+
const envelopeSnapshot = join8(snapshotDirectory, `${envelopeDigest}.envelope.json`);
|
|
4551
5387
|
writeSnapshot(manifestSnapshot, manifestBytes);
|
|
4552
5388
|
writeSnapshot(envelopeSnapshot, envelopeBytes);
|
|
4553
5389
|
chmodSync2(snapshotDirectory, 320);
|
|
@@ -4568,7 +5404,7 @@ function readBoundedRegularFile(path, label, maximum) {
|
|
|
4568
5404
|
if (!metadata.isFile() || metadata.size < 1 || metadata.size > maximum) {
|
|
4569
5405
|
throw new Error(`${label} must be a non-empty bounded regular file`);
|
|
4570
5406
|
}
|
|
4571
|
-
return
|
|
5407
|
+
return readFileSync4(descriptor);
|
|
4572
5408
|
} finally {
|
|
4573
5409
|
closeSync2(descriptor);
|
|
4574
5410
|
}
|
|
@@ -4659,8 +5495,8 @@ function writeSnapshot(path, bytes) {
|
|
|
4659
5495
|
}
|
|
4660
5496
|
|
|
4661
5497
|
// src/cli/desktop-snapshot.ts
|
|
4662
|
-
import { chmodSync as chmodSync3, mkdirSync as mkdirSync4, mkdtempSync as mkdtempSync3, renameSync as renameSync2, rmSync as rmSync5, statSync as
|
|
4663
|
-
import { dirname as dirname4, join as
|
|
5498
|
+
import { chmodSync as chmodSync3, mkdirSync as mkdirSync4, mkdtempSync as mkdtempSync3, renameSync as renameSync2, rmSync as rmSync5, statSync as statSync6 } from "fs";
|
|
5499
|
+
import { dirname as dirname4, join as join9, resolve as resolve3 } from "path";
|
|
4664
5500
|
import { spawnSync as spawnSync6 } from "child_process";
|
|
4665
5501
|
var SCREEN_CAPTURE_EXECUTABLE = "/usr/sbin/screencapture";
|
|
4666
5502
|
var DEFAULT_DESKTOP_SNAPSHOT = "desktop-snapshot.png";
|
|
@@ -4682,9 +5518,9 @@ function exportDesktopSnapshot(output = DEFAULT_DESKTOP_SNAPSHOT, dependencies =
|
|
|
4682
5518
|
const destination = resolve3(dependencies.cwd ?? process.cwd(), output);
|
|
4683
5519
|
const destinationDirectory = dirname4(destination);
|
|
4684
5520
|
mkdirSync4(destinationDirectory, { recursive: true });
|
|
4685
|
-
const stagingDirectory = mkdtempSync3(
|
|
5521
|
+
const stagingDirectory = mkdtempSync3(join9(destinationDirectory, ".recordings-desktop-"));
|
|
4686
5522
|
chmodSync3(stagingDirectory, 448);
|
|
4687
|
-
const stagingPath =
|
|
5523
|
+
const stagingPath = join9(stagingDirectory, "snapshot.png");
|
|
4688
5524
|
try {
|
|
4689
5525
|
const capture = dependencies.capture ?? runScreenCapture;
|
|
4690
5526
|
const result = capture(SCREEN_CAPTURE_EXECUTABLE, [
|
|
@@ -4704,7 +5540,7 @@ function exportDesktopSnapshot(output = DEFAULT_DESKTOP_SNAPSHOT, dependencies =
|
|
|
4704
5540
|
}
|
|
4705
5541
|
let size = 0;
|
|
4706
5542
|
try {
|
|
4707
|
-
const snapshot =
|
|
5543
|
+
const snapshot = statSync6(stagingPath);
|
|
4708
5544
|
if (snapshot.isFile())
|
|
4709
5545
|
size = snapshot.size;
|
|
4710
5546
|
} catch {}
|
|
@@ -5153,10 +5989,10 @@ program.command("projects").description("List registered projects").option("-n,
|
|
|
5153
5989
|
});
|
|
5154
5990
|
program.command("init").description("Initialize .recordings/ in current directory").action(() => {
|
|
5155
5991
|
const { mkdirSync: mkdirSync5, writeFileSync: writeFileSync4, existsSync: existsSync9 } = __require("fs");
|
|
5156
|
-
const { join:
|
|
5157
|
-
const dir =
|
|
5158
|
-
const audioDir =
|
|
5159
|
-
const configFile =
|
|
5992
|
+
const { join: join10 } = __require("path");
|
|
5993
|
+
const dir = join10(process.cwd(), ".recordings");
|
|
5994
|
+
const audioDir = join10(dir, "audio");
|
|
5995
|
+
const configFile = join10(dir, "config.json");
|
|
5160
5996
|
mkdirSync5(audioDir, { recursive: true });
|
|
5161
5997
|
if (!existsSync9(configFile)) {
|
|
5162
5998
|
const defaultConf = {
|
|
@@ -5179,9 +6015,9 @@ program.command("init").description("Initialize .recordings/ in current director
|
|
|
5179
6015
|
console.log(chalk.dim(" db: .recordings/recordings.db"));
|
|
5180
6016
|
});
|
|
5181
6017
|
var appCommand = program.command("app").description("Manage the macOS app installed from this package");
|
|
5182
|
-
appCommand.command("install").description("Install a release or explicitly approved local-only
|
|
6018
|
+
appCommand.command("install").description("Install a release or explicitly approved local-only HasnaRecordings.app artifact").requiredOption("--artifact <path>", "Finalized HasnaRecordings.app ZIP artifact").requiredOption("--manifest <path>", "Artifact provenance manifest").option("--envelope <path>", "Signed release envelope (required for release artifacts)").option("--expected-team-id <team>", "Required Developer ID TeamIdentifier for release artifacts").requiredOption("--manifest-sha256 <sha256>", "Authenticated release-manifest SHA-256").requiredOption("--expected-source-sha <sha>", "Exact approved 40-character source commit").requiredOption("--expected-version <version>", "Exact approved release version").option("--expected-hostname <hostname>", "Exact deployment hostname to verify before any install mutation").option("--artifact-policy <policy>", "Artifact policy: release or local-only", "release").option("--approved-target <station>", "Exact approved target; fleet for release artifacts", "fleet").option("--variant <variant>", "Artifact variant the operator intends to install: full or bar (default: full)", "full").option("--approved-target-identity-kind <kind>", "Target identity kind: hardware_uuid_sha256 or tailscale_node_id_sha256").option("--approved-target-identity-sha256 <sha256>", "Authenticated SHA-256 of the approved target identity; none for release artifacts", "none").option("--acknowledge-local-signing-and-permissions", "Acknowledge local-only ad-hoc identity and possible permission reauthorization").option("--expected-old-identity-sha256 <sha256>", "Exact installed identity approved for migration").option("--expected-new-identity-sha256 <sha256>", "Exact candidate identity approved for migration").option("--allow-signing-identity-migration", "Allow one reviewed signer change that requires new macOS permission approval").option("--allow-adhoc-identity-migration", "Accept that replacing an ad-hoc signed local-only app voids its Microphone and Accessibility grants").option("--launch", "Launch and verify the canonical app after installation").option("--launch-timeout <seconds>", "Canonical process launch timeout").action((opts) => {
|
|
5183
6019
|
if (process.platform !== "darwin") {
|
|
5184
|
-
console.error(chalk.red("
|
|
6020
|
+
console.error(chalk.red("HasnaRecordings.app installation is only supported on macOS"));
|
|
5185
6021
|
process.exit(1);
|
|
5186
6022
|
}
|
|
5187
6023
|
if (opts.variant !== "full" && opts.variant !== "bar") {
|
|
@@ -5229,10 +6065,14 @@ appCommand.command("install").description("Install a release or explicitly appro
|
|
|
5229
6065
|
console.error(chalk.red(error instanceof Error ? error.message : String(error)));
|
|
5230
6066
|
process.exit(1);
|
|
5231
6067
|
}
|
|
5232
|
-
const
|
|
6068
|
+
const home = process.env.HOME || process.env.USERPROFILE || "";
|
|
6069
|
+
const canonicalAppPath = pathJoin(home, "Applications", "HasnaRecordings.app");
|
|
6070
|
+
const legacyInstallPaths = findLegacyMacOSAppPaths(home, canonicalAppPath);
|
|
6071
|
+
const installedAppPath = resolveInstalledAppPath(home, canonicalAppPath, legacyInstallPaths);
|
|
6072
|
+
const updateClientPath = pathJoin(installedAppPath, "Contents", "Helpers", "recordings-update-client");
|
|
5233
6073
|
if (!existsSync8(updateClientPath)) {
|
|
5234
6074
|
preparedInputs.cleanup();
|
|
5235
|
-
console.error(chalk.red(
|
|
6075
|
+
console.error(chalk.red(`Root-owned Recordings update broker client is not installed at ${updateClientPath}.`));
|
|
5236
6076
|
process.exit(1);
|
|
5237
6077
|
}
|
|
5238
6078
|
const result2 = (() => {
|
|
@@ -5346,7 +6186,7 @@ appCommand.command("install").description("Install a release or explicitly appro
|
|
|
5346
6186
|
}
|
|
5347
6187
|
process.exit(result.status ?? 1);
|
|
5348
6188
|
});
|
|
5349
|
-
appCommand.command("status").description("Show installed
|
|
6189
|
+
appCommand.command("status").description("Show installed HasnaRecordings.app status").option("--verbose", "Show package paths, code hash, and log path").action((opts) => {
|
|
5350
6190
|
const status = getMacOSAppStatus();
|
|
5351
6191
|
const trigger = probeTriggerDiagnostics({
|
|
5352
6192
|
accessibilityPermission: process.platform === "darwin" ? status.accessibility_permission : null,
|
|
@@ -5356,7 +6196,7 @@ appCommand.command("status").description("Show installed Recordings.app status")
|
|
|
5356
6196
|
console.log(JSON.stringify({ ...status, trigger }, null, 2));
|
|
5357
6197
|
return;
|
|
5358
6198
|
}
|
|
5359
|
-
console.log(chalk.bold("Recordings.app"));
|
|
6199
|
+
console.log(chalk.bold("Hasna Recordings.app"));
|
|
5360
6200
|
console.log(`Installed: ${status.installed ? "yes" : "no"}`);
|
|
5361
6201
|
console.log(`Executable: ${status.executable ? "available" : "missing"}`);
|
|
5362
6202
|
console.log(`Installer: ${status.installer_available ? "available" : "missing"}`);
|
|
@@ -5386,7 +6226,7 @@ appCommand.command("status").description("Show installed Recordings.app status")
|
|
|
5386
6226
|
console.log(chalk.dim("Use --verbose for paths/code hash/log, or --json for the full status object."));
|
|
5387
6227
|
}
|
|
5388
6228
|
});
|
|
5389
|
-
appCommand.command("permissions").description("Show macOS permission state for
|
|
6229
|
+
appCommand.command("permissions").description("Show macOS permission state for HasnaRecordings.app").action(() => {
|
|
5390
6230
|
const status = getMacOSAppStatus();
|
|
5391
6231
|
const permissions = {
|
|
5392
6232
|
platform: status.platform,
|
|
@@ -5422,21 +6262,21 @@ appCommand.command("permissions").description("Show macOS permission state for R
|
|
|
5422
6262
|
}
|
|
5423
6263
|
console.log(`Log: ${permissions.log_path}`);
|
|
5424
6264
|
});
|
|
5425
|
-
appCommand.command("reset-permissions").description("Reset macOS Microphone and Accessibility permissions for
|
|
6265
|
+
appCommand.command("reset-permissions").description("Reset macOS Microphone and Accessibility permissions for HasnaRecordings.app").action(() => {
|
|
5426
6266
|
if (process.platform !== "darwin") {
|
|
5427
6267
|
console.error(chalk.red("Permission reset is only available on macOS"));
|
|
5428
6268
|
process.exit(1);
|
|
5429
6269
|
}
|
|
5430
6270
|
resetMacOSPermissions();
|
|
5431
6271
|
});
|
|
5432
|
-
appCommand.command("request-permissions").description("Open
|
|
6272
|
+
appCommand.command("request-permissions").description("Open HasnaRecordings.app and trigger macOS Microphone and Accessibility permission prompts").option("--reset", "Reset existing Microphone and Accessibility decisions before requesting").action((opts) => {
|
|
5433
6273
|
if (process.platform !== "darwin") {
|
|
5434
6274
|
console.error(chalk.red("Permission prompts are only available on macOS"));
|
|
5435
6275
|
process.exit(1);
|
|
5436
6276
|
}
|
|
5437
6277
|
const status = getMacOSAppStatus();
|
|
5438
6278
|
if (!status.installed) {
|
|
5439
|
-
console.error(chalk.red("
|
|
6279
|
+
console.error(chalk.red("HasnaRecordings.app is not installed. Run: recordings app install"));
|
|
5440
6280
|
process.exit(1);
|
|
5441
6281
|
}
|
|
5442
6282
|
if (opts.reset) {
|
|
@@ -5448,7 +6288,7 @@ appCommand.command("request-permissions").description("Open Recordings.app and t
|
|
|
5448
6288
|
}
|
|
5449
6289
|
process.exit(result.exitCode);
|
|
5450
6290
|
});
|
|
5451
|
-
appCommand.command("log").description("Show the
|
|
6291
|
+
appCommand.command("log").description("Show the HasnaRecordings.app diagnostic log").option("-n, --lines <lines>", "Number of lines to print", String(DEFAULT_LOG_LINES)).action((opts) => {
|
|
5452
6292
|
const status = getMacOSAppStatus();
|
|
5453
6293
|
if (!existsSync8(status.log_path)) {
|
|
5454
6294
|
console.log("");
|
|
@@ -5474,14 +6314,14 @@ appCommand.command("snapshot [output]").description("Write the current main desk
|
|
|
5474
6314
|
console.log(chalk.green(`Desktop snapshot written: ${path}`));
|
|
5475
6315
|
}
|
|
5476
6316
|
});
|
|
5477
|
-
appCommand.command("open").description("Open the installed
|
|
6317
|
+
appCommand.command("open").description("Open the installed HasnaRecordings.app").action(() => {
|
|
5478
6318
|
const status = getMacOSAppStatus();
|
|
5479
6319
|
if (process.platform !== "darwin") {
|
|
5480
|
-
console.error(chalk.red("
|
|
6320
|
+
console.error(chalk.red("HasnaRecordings.app can only be opened on macOS"));
|
|
5481
6321
|
process.exit(1);
|
|
5482
6322
|
}
|
|
5483
6323
|
if (!status.installed) {
|
|
5484
|
-
console.error(chalk.red("
|
|
6324
|
+
console.error(chalk.red("HasnaRecordings.app is not installed. Run: recordings app install"));
|
|
5485
6325
|
process.exit(1);
|
|
5486
6326
|
}
|
|
5487
6327
|
const result = spawnSync7("open", [status.installed_app_path], { stdio: "inherit" });
|
|
@@ -5613,7 +6453,7 @@ program.command("check").description("Check system dependencies (sox, API keys)
|
|
|
5613
6453
|
console.log(chalk.dim(" Could not read the TCC database. This is NOT a denial and NOT proof the app never " + "asked. Reading it needs Full Disk Access, which is held by the session's " + "RESPONSIBLE process and inherited by its children \u2014 not granted per-tool: on " + "a fleet Mac `bun` is explicitly denied and still reads the database over SSH, " + "because it inherits sshd's grant. So re-run from a plain ssh shell rather than " + "granting Full Disk Access to tmux or bun, and not under sudo, which changes the " + "responsible process. A missing sqlite3, a locked database or a corrupt file " + "produce this same state, so the cause is not established either."));
|
|
5614
6454
|
}
|
|
5615
6455
|
if (!micOk && !micUnreadable) {
|
|
5616
|
-
console.log(chalk.dim("
|
|
6456
|
+
console.log(chalk.dim(" HasnaRecordings.app cannot capture audio without this. macOS does not error when it is " + "missing \u2014 it delivers silent audio. This grant CANNOT be set remotely; it needs a " + "human at the keyboard:"));
|
|
5617
6457
|
const instruction = microphoneGrantInstruction({
|
|
5618
6458
|
installedAppPath: macStatus.installed_app_path,
|
|
5619
6459
|
otherAppPaths: macStatus.legacy_install_paths,
|
|
@@ -5772,7 +6612,7 @@ Bye.`));
|
|
|
5772
6612
|
}
|
|
5773
6613
|
});
|
|
5774
6614
|
});
|
|
5775
|
-
program.command("shortcut").description("Show or change the global recording trigger (macOS). Exits non-zero when a change was " + "written while
|
|
6615
|
+
program.command("shortcut").description("Show or change the global recording trigger (macOS). Exits non-zero when a change was " + "written while HasnaRecordings.app was running, because the running instance keeps the " + "trigger it registered with \u2014 the write is stored but not armed until it is reopened.").option("--set <chord>", 'Set the app hotkey, e.g. "f13" or "ctrl+opt+r"').option("--reset", "Reset the app hotkey to the app's built-in default").option("--fn <state>", "Use fn/Globe as push-to-talk: on|off").option("--keys", "List the key names accepted by --set").option("--script", "Write the app-less toggle script and print its path").option("--raycast", "Generate a Raycast script command (requires Raycast)").option("--karabiner", "Generate a Karabiner-Elements rule (requires Karabiner-Elements)").option("--skhd", "Print an skhd hotkey config (requires skhd)").option("--hammerspoon", "Print a Hammerspoon config (requires Hammerspoon)").action((opts) => {
|
|
5776
6616
|
const { writeFileSync: writeFileSync4, mkdirSync: mkdirSync5, chmodSync: chmodSync4, existsSync: existsSync9 } = __require("fs");
|
|
5777
6617
|
const { join: pathJoin2 } = __require("path");
|
|
5778
6618
|
const { homedir: getHome } = __require("os");
|
|
@@ -5886,10 +6726,10 @@ program.command("shortcut").description("Show or change the global recording tri
|
|
|
5886
6726
|
function reportPickup() {
|
|
5887
6727
|
const pickup = describeTriggerPickup(runningBundles());
|
|
5888
6728
|
if (pickup.armed) {
|
|
5889
|
-
console.log(chalk.dim("
|
|
6729
|
+
console.log(chalk.dim(" HasnaRecordings.app is not running; it will register this on next launch."));
|
|
5890
6730
|
return;
|
|
5891
6731
|
}
|
|
5892
|
-
console.log(chalk.yellow("
|
|
6732
|
+
console.log(chalk.yellow(" HasnaRecordings.app is running and still holds the previous trigger.") + `
|
|
5893
6733
|
Quit and reopen it to arm this one:`);
|
|
5894
6734
|
for (const path of pickup.runningBundlePaths)
|
|
5895
6735
|
console.log(chalk.dim(` ${path}`));
|
|
@@ -5948,7 +6788,7 @@ program.command("shortcut").description("Show or change the global recording tri
|
|
|
5948
6788
|
` + ` ${fnGrant.settingsPath} > enable Recordings`);
|
|
5949
6789
|
const target = grantTargetPaths();
|
|
5950
6790
|
if (target.paths.length === 0) {
|
|
5951
|
-
console.log(chalk.dim(" (no installed
|
|
6791
|
+
console.log(chalk.dim(" (no installed HasnaRecordings.app found to grant it to)"));
|
|
5952
6792
|
}
|
|
5953
6793
|
for (const path of target.paths) {
|
|
5954
6794
|
console.log(chalk.dim(` grant it to: ${path}${target.running ? "" : " (not running \u2014 installed copy)"}`));
|
|
@@ -5975,7 +6815,7 @@ program.command("shortcut").description("Show or change the global recording tri
|
|
|
5975
6815
|
# Toggle recording on/off. Run this from a global hotkey.
|
|
5976
6816
|
# Each press toggles: start recording -> stop + transcribe + copy to clipboard
|
|
5977
6817
|
#
|
|
5978
|
-
# This is the app-less path. If
|
|
6818
|
+
# This is the app-less path. If HasnaRecordings.app is running, prefer its own hotkey
|
|
5979
6819
|
# ("recordings shortcut --set ..."): the app streams transcription and pastes into
|
|
5980
6820
|
# the focused field, which this script cannot do.
|
|
5981
6821
|
set -e
|
|
@@ -6046,7 +6886,7 @@ fi
|
|
|
6046
6886
|
title: "Recordings \u2014 Fn key to toggle recording",
|
|
6047
6887
|
rules: [
|
|
6048
6888
|
{
|
|
6049
|
-
description: "Fn key toggles speech recording (
|
|
6889
|
+
description: "Fn key toggles speech recording (recordings)",
|
|
6050
6890
|
manipulators: [
|
|
6051
6891
|
{
|
|
6052
6892
|
type: "basic",
|
|
@@ -6172,7 +7012,7 @@ async function readSaveTextInput(text, opts) {
|
|
|
6172
7012
|
}
|
|
6173
7013
|
let rawText;
|
|
6174
7014
|
if (opts.textFile !== undefined) {
|
|
6175
|
-
rawText =
|
|
7015
|
+
rawText = readFileSync5(opts.textFile, "utf8");
|
|
6176
7016
|
} else if (opts.stdin) {
|
|
6177
7017
|
rawText = await Bun.stdin.text();
|
|
6178
7018
|
} else {
|
|
@@ -6386,7 +7226,7 @@ function relativeHint(value) {
|
|
|
6386
7226
|
return `${Math.floor(hours / 24)}d ago`;
|
|
6387
7227
|
}
|
|
6388
7228
|
program.command("mcp").description("Install recordings MCP server into Claude Code, Codex, or Gemini").option("--claude", "Install into Claude Code (via `claude mcp add`)").option("--codex", "Install into Codex (~/.codex/config.toml)").option("--gemini", "Install into Gemini (~/.gemini/settings.json)").option("--all", "Install into all supported agents").option("--uninstall", "Remove recordings MCP from config").action(async (opts) => {
|
|
6389
|
-
const { readFileSync:
|
|
7229
|
+
const { readFileSync: readFileSync6, writeFileSync: writeFileSync4, existsSync: fileExists } = __require("fs");
|
|
6390
7230
|
const { join: pathJoin2 } = __require("path");
|
|
6391
7231
|
const { homedir: getHome } = __require("os");
|
|
6392
7232
|
const { execSync } = __require("child_process");
|
|
@@ -6419,7 +7259,7 @@ program.command("mcp").description("Install recordings MCP server into Claude Co
|
|
|
6419
7259
|
if (target === "codex") {
|
|
6420
7260
|
const configPath = pathJoin2(home, ".codex", "config.toml");
|
|
6421
7261
|
if (fileExists(configPath)) {
|
|
6422
|
-
const content =
|
|
7262
|
+
const content = readFileSync6(configPath, "utf-8");
|
|
6423
7263
|
if (opts.uninstall) {
|
|
6424
7264
|
const { content: next, removed } = removeCodexServerBlock(content, "recordings");
|
|
6425
7265
|
writeFileSync4(configPath, next, "utf-8");
|
|
@@ -6437,7 +7277,7 @@ program.command("mcp").description("Install recordings MCP server into Claude Co
|
|
|
6437
7277
|
const configPath = pathJoin2(home, ".gemini", "settings.json");
|
|
6438
7278
|
let config = {};
|
|
6439
7279
|
if (fileExists(configPath)) {
|
|
6440
|
-
config = JSON.parse(
|
|
7280
|
+
config = JSON.parse(readFileSync6(configPath, "utf-8"));
|
|
6441
7281
|
}
|
|
6442
7282
|
const servers = config["mcpServers"] || {};
|
|
6443
7283
|
if (opts.uninstall) {
|
|
@@ -6476,7 +7316,7 @@ program.command("feedback <message>").description("Send feedback").option("--ema
|
|
|
6476
7316
|
function getMacOSAppStatus() {
|
|
6477
7317
|
const packageRoot = findPackageRoot();
|
|
6478
7318
|
const home = process.env.HOME || process.env.USERPROFILE || "";
|
|
6479
|
-
const canonicalAppPath = pathJoin(home, "Applications", "
|
|
7319
|
+
const canonicalAppPath = pathJoin(home, "Applications", "HasnaRecordings.app");
|
|
6480
7320
|
const legacyInstallPaths = findLegacyMacOSAppPaths(home, canonicalAppPath);
|
|
6481
7321
|
const installedAppPath = resolveInstalledAppPath(home, canonicalAppPath, legacyInstallPaths);
|
|
6482
7322
|
const installedAppPathForGrants = existsSync8(installedAppPath) ? installedAppPath : null;
|
|
@@ -6524,7 +7364,7 @@ function buildPermissionWarnings(status) {
|
|
|
6524
7364
|
warnings.push(`no app bundle exists at ${status.installed_app_path}, so the states above describe no ` + "installed code \u2014 install the app before trusting them");
|
|
6525
7365
|
}
|
|
6526
7366
|
if (status.ambiguous_installations) {
|
|
6527
|
-
warnings.push("more than one
|
|
7367
|
+
warnings.push("more than one HasnaRecordings.app is installed, so the states above may describe a bundle " + `other than the one macOS granted: reporting on ${status.installed_app_path}, also ` + `present are ${status.legacy_install_paths.join(", ")}`);
|
|
6528
7368
|
}
|
|
6529
7369
|
for (const [service, durability] of [
|
|
6530
7370
|
["Microphone", status.microphone_grant_durability],
|
|
@@ -6625,7 +7465,7 @@ function findPackageRoot() {
|
|
|
6625
7465
|
const packagePath = pathJoin(current, "package.json");
|
|
6626
7466
|
if (existsSync8(packagePath)) {
|
|
6627
7467
|
try {
|
|
6628
|
-
const pkg = JSON.parse(
|
|
7468
|
+
const pkg = JSON.parse(readFileSync5(packagePath, "utf8"));
|
|
6629
7469
|
if (pkg.name === "@hasna/recordings") {
|
|
6630
7470
|
return current;
|
|
6631
7471
|
}
|