@hasna/recordings 0.3.12 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +223 -37
- package/bun.lock +37 -38
- package/dist/cli/index.js +1884 -615
- package/dist/cli/macos-shortcut.d.ts +2 -2
- package/dist/db/database.d.ts.map +1 -1
- package/dist/db/pg-migrations.d.ts.map +1 -1
- package/dist/db/recordings.d.ts +6 -0
- package/dist/db/recordings.d.ts.map +1 -1
- package/dist/http/client.d.ts +183 -22
- package/dist/http/client.d.ts.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2858 -2288
- package/dist/lib/audio-artifact-storage.d.ts +98 -0
- package/dist/lib/audio-artifact-storage.d.ts.map +1 -0
- 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/local-opt-in.d.ts +132 -0
- package/dist/lib/local-opt-in.d.ts.map +1 -0
- package/dist/lib/macos-bundle.d.ts +1 -1
- package/dist/lib/persistence-probe.d.ts +56 -17
- package/dist/lib/persistence-probe.d.ts.map +1 -1
- package/dist/lib/transcriber.d.ts.map +1 -1
- package/dist/mcp/index.d.ts.map +1 -1
- package/dist/mcp/index.js +1689 -456
- package/dist/sdk/index.d.ts +13 -5
- package/dist/sdk/index.d.ts.map +1 -1
- package/dist/sdk/index.js +904 -1
- package/dist/sdk/resolve.d.ts +53 -0
- package/dist/sdk/resolve.d.ts.map +1 -0
- package/dist/sdk/v1.generated.d.ts +6 -0
- package/dist/sdk/v1.generated.d.ts.map +1 -1
- package/dist/server/client-ip.d.ts +39 -0
- package/dist/server/client-ip.d.ts.map +1 -0
- package/dist/server/cloud-config.d.ts +12 -0
- package/dist/server/cloud-config.d.ts.map +1 -1
- package/dist/server/cloud-readiness.d.ts.map +1 -1
- package/dist/server/cloud.d.ts +20 -2
- package/dist/server/cloud.d.ts.map +1 -1
- package/dist/server/index.js +566 -284
- package/dist/server/migrate-command.d.ts +1 -1
- package/dist/server/migrate-command.d.ts.map +1 -1
- package/dist/server/openapi.d.ts +21 -0
- package/dist/server/openapi.d.ts.map +1 -1
- package/dist/server/repo.d.ts.map +1 -1
- package/dist/server/serve.d.ts.map +1 -1
- package/dist/storage.d.ts +2 -2
- package/dist/storage.d.ts.map +1 -1
- package/dist/storage.js +1334 -871
- package/dist/store.d.ts +12 -3
- package/dist/store.d.ts.map +1 -1
- package/dist/types/index.d.ts +15 -0
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +9 -8
- package/packaging/macos/build_release_pkg.sh +3 -3
- package/packaging/macos/managed_bootstrap.sh +4 -4
- package/packaging/macos/scripts/postinstall +2 -2
- package/packaging/macos/scripts/preinstall +2 -2
- package/scripts/build_companion_cli.sh +7 -1
- package/scripts/install_macos_app.sh +27 -24
- package/scripts/macos_artifact.ts +20 -16
- package/scripts/migrate.ts +4 -20
- package/scripts/smoke_macos_app.sh +21 -21
- package/src/native/Recordings/App/ContentView.swift +53 -68
- package/src/native/Recordings/App/MenuBarStatusView.swift +171 -61
- package/src/native/Recordings/App/RecordWorkspaceView.swift +47 -449
- package/src/native/Recordings/App/RecorderSettingsView.swift +75 -0
- package/src/native/Recordings/App/RecordingDetailView.swift +3 -4
- package/src/native/Recordings/App/RecordingsApp.swift +185 -9
- package/src/native/Recordings/App/RecordingsListView.swift +60 -112
- package/src/native/Recordings/App/RecordingsStore.swift +124 -109
- package/src/native/Recordings/App/Theme.swift +73 -61
- package/src/native/Recordings/Package.swift +1 -0
- package/src/native/Recordings/RecordingsLib/CaptureMonitor.swift +65 -0
- package/src/native/Recordings/RecordingsLib/Info.plist +3 -3
- package/src/native/Recordings/RecordingsLib/OpenAIAPIKeyStore.swift +75 -21
- package/src/native/Recordings/RecordingsLib/Recording.swift +14 -2
- package/src/native/Recordings/RecordingsLib/RecordingEngine.swift +316 -34
- package/src/native/Recordings/RecordingsLib/RecordingGlassBackground.swift +34 -0
- package/src/native/Recordings/RecordingsLib/RecordingProvider.swift +102 -0
- package/src/native/Recordings/RecordingsLib/RecordingsCLI.swift +17 -10
- package/src/native/Recordings/RecordingsLib/ServiceAPIConfiguration.swift +107 -0
- package/src/native/Recordings/RecordingsLib/ServiceConnectionView.swift +76 -0
- package/src/native/Recordings/RecordingsLib/SettingsView.swift +45 -149
- package/src/native/Recordings/RecordingsLib/SettingsWindowController.swift +48 -0
- package/src/native/Recordings/RecordingsTests/CLIRunnerTests.swift +13 -4
- package/src/native/Recordings/RecordingsTests/OpenAIAPIKeyStoreTests.swift +43 -38
- package/src/native/Recordings/RecordingsTests/RecorderSurfaceTests.swift +60 -0
- package/src/native/Recordings/RecordingsTests/RecordingDateTests.swift +26 -0
- package/src/native/Recordings/RecordingsTests/RecordingProviderTests.swift +195 -0
- package/src/native/Recordings/RecordingsTests/RecordingStartGateTests.swift +1 -1
- package/src/native/Recordings/RecordingsTests/RecordingStartTimingTests.swift +30 -0
- package/src/native/Recordings/RecordingsTests/ServiceAPIConfigurationTests.swift +75 -0
- package/src/native/Recordings/RecordingsTests/SettingsWindowControllerTests.swift +28 -0
- 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 +6 -6
- package/src/native/Recordings/App/SidebarView.swift +0 -212
package/dist/cli/index.js
CHANGED
|
@@ -900,10 +900,10 @@ function collectValues(value, previous) {
|
|
|
900
900
|
|
|
901
901
|
// src/cli/index.ts
|
|
902
902
|
import chalk from "chalk";
|
|
903
|
-
import { spawnSync as
|
|
903
|
+
import { spawnSync as spawnSync9 } from "child_process";
|
|
904
904
|
import {
|
|
905
905
|
existsSync as existsSync8,
|
|
906
|
-
readFileSync as
|
|
906
|
+
readFileSync as readFileSync6,
|
|
907
907
|
readdirSync as readdirSync2
|
|
908
908
|
} from "fs";
|
|
909
909
|
import { dirname as dirname5, join as pathJoin } from "path";
|
|
@@ -1117,6 +1117,9 @@ var DEFAULT_CONFIG = {
|
|
|
1117
1117
|
db_path: "",
|
|
1118
1118
|
audio_dir: "",
|
|
1119
1119
|
max_recording_seconds: 1800,
|
|
1120
|
+
s3_bucket: "",
|
|
1121
|
+
s3_prefix: "",
|
|
1122
|
+
s3_region: "",
|
|
1120
1123
|
config_warnings: []
|
|
1121
1124
|
};
|
|
1122
1125
|
function loadConfig(configPath) {
|
|
@@ -1143,6 +1146,9 @@ function loadConfig(configPath) {
|
|
|
1143
1146
|
if (process.env.RECORDINGS_API_KEY) {
|
|
1144
1147
|
config.openai_api_key = process.env.RECORDINGS_API_KEY;
|
|
1145
1148
|
}
|
|
1149
|
+
if (process.env.RECORDINGS_OPENAI_API_KEY) {
|
|
1150
|
+
config.openai_api_key = process.env.RECORDINGS_OPENAI_API_KEY;
|
|
1151
|
+
}
|
|
1146
1152
|
if (process.env.RECORDINGS_ENHANCEMENT_KEY) {
|
|
1147
1153
|
config.enhancement_api_key = process.env.RECORDINGS_ENHANCEMENT_KEY;
|
|
1148
1154
|
}
|
|
@@ -1186,14 +1192,26 @@ function loadConfig(configPath) {
|
|
|
1186
1192
|
if (process.env.RECORDINGS_AUDIO_DIR) {
|
|
1187
1193
|
config.audio_dir = process.env.RECORDINGS_AUDIO_DIR;
|
|
1188
1194
|
}
|
|
1195
|
+
if (process.env.HASNA_RECORDINGS_S3_BUCKET) {
|
|
1196
|
+
config.s3_bucket = process.env.HASNA_RECORDINGS_S3_BUCKET;
|
|
1197
|
+
} else if (process.env.RECORDINGS_S3_BUCKET) {
|
|
1198
|
+
config.s3_bucket = process.env.RECORDINGS_S3_BUCKET;
|
|
1199
|
+
}
|
|
1200
|
+
if (process.env.HASNA_RECORDINGS_S3_PREFIX) {
|
|
1201
|
+
config.s3_prefix = process.env.HASNA_RECORDINGS_S3_PREFIX;
|
|
1202
|
+
} else if (process.env.RECORDINGS_S3_PREFIX) {
|
|
1203
|
+
config.s3_prefix = process.env.RECORDINGS_S3_PREFIX;
|
|
1204
|
+
}
|
|
1205
|
+
if (process.env.RECORDINGS_S3_REGION) {
|
|
1206
|
+
config.s3_region = process.env.RECORDINGS_S3_REGION;
|
|
1207
|
+
} else if (process.env.AWS_REGION) {
|
|
1208
|
+
config.s3_region = process.env.AWS_REGION;
|
|
1209
|
+
}
|
|
1189
1210
|
if (process.env.RECORDINGS_MAX_SECONDS) {
|
|
1190
1211
|
config.max_recording_seconds = parseInt(process.env.RECORDINGS_MAX_SECONDS, 10);
|
|
1191
1212
|
}
|
|
1192
|
-
if (!config.openai_api_key) {
|
|
1193
|
-
config.openai_api_key = loadSecretKey("OPENAI_API_KEY");
|
|
1194
|
-
}
|
|
1195
1213
|
if (!config.enhancement_api_key) {
|
|
1196
|
-
config.enhancement_api_key = config.openai_api_key
|
|
1214
|
+
config.enhancement_api_key = config.openai_api_key;
|
|
1197
1215
|
}
|
|
1198
1216
|
if (!explicitTranscriberModel) {
|
|
1199
1217
|
config.transcriber_model = config.enhancement_model;
|
|
@@ -1373,51 +1391,6 @@ function mergeDirectoryContents(sourceDir, targetDir) {
|
|
|
1373
1391
|
}
|
|
1374
1392
|
}
|
|
1375
1393
|
}
|
|
1376
|
-
function loadSecretKey(keyName) {
|
|
1377
|
-
const secretsPath = join3(getHomeDir(), ".secrets");
|
|
1378
|
-
if (!existsSync2(secretsPath))
|
|
1379
|
-
return "";
|
|
1380
|
-
for (const candidate of listSecretFiles(secretsPath)) {
|
|
1381
|
-
try {
|
|
1382
|
-
const content = readFileSync(candidate, "utf-8");
|
|
1383
|
-
const match = content.match(new RegExp(`export\\s+${keyName}\\s*=\\s*"([^"]+)"`));
|
|
1384
|
-
const doubleQuotedValue = match?.[1];
|
|
1385
|
-
if (doubleQuotedValue !== undefined)
|
|
1386
|
-
return doubleQuotedValue;
|
|
1387
|
-
const match2 = content.match(new RegExp(`export\\s+${keyName}\\s*=\\s*'([^']+)'`));
|
|
1388
|
-
const singleQuotedValue = match2?.[1];
|
|
1389
|
-
if (singleQuotedValue !== undefined)
|
|
1390
|
-
return singleQuotedValue;
|
|
1391
|
-
const match3 = content.match(new RegExp(`${keyName}\\s*=\\s*(.+)`));
|
|
1392
|
-
const unquotedValue = match3?.[1];
|
|
1393
|
-
if (unquotedValue !== undefined)
|
|
1394
|
-
return unquotedValue.trim().replace(/^["']|["']$/g, "");
|
|
1395
|
-
} catch {}
|
|
1396
|
-
}
|
|
1397
|
-
return "";
|
|
1398
|
-
}
|
|
1399
|
-
function listSecretFiles(path) {
|
|
1400
|
-
try {
|
|
1401
|
-
const stats = statSync(path);
|
|
1402
|
-
if (stats.isFile())
|
|
1403
|
-
return [path];
|
|
1404
|
-
if (!stats.isDirectory())
|
|
1405
|
-
return [];
|
|
1406
|
-
return readdirSync(path).sort().flatMap((entry) => {
|
|
1407
|
-
const child = join3(path, entry);
|
|
1408
|
-
try {
|
|
1409
|
-
const childStats = statSync(child);
|
|
1410
|
-
if (childStats.isDirectory())
|
|
1411
|
-
return listSecretFiles(child);
|
|
1412
|
-
if (childStats.isFile() && child.endsWith(".env"))
|
|
1413
|
-
return [child];
|
|
1414
|
-
} catch {}
|
|
1415
|
-
return [];
|
|
1416
|
-
});
|
|
1417
|
-
} catch {
|
|
1418
|
-
return [];
|
|
1419
|
-
}
|
|
1420
|
-
}
|
|
1421
1394
|
function getHomeDir() {
|
|
1422
1395
|
return process.env["HOME"] || process.env["USERPROFILE"] || homedir3();
|
|
1423
1396
|
}
|
|
@@ -1547,6 +1520,11 @@ var MIGRATIONS = [
|
|
|
1547
1520
|
`,
|
|
1548
1521
|
`
|
|
1549
1522
|
ALTER TABLE agents ADD COLUMN active_project_id TEXT REFERENCES projects(id) ON DELETE SET NULL;
|
|
1523
|
+
`,
|
|
1524
|
+
`
|
|
1525
|
+
ALTER TABLE recordings ADD COLUMN audio_object_key TEXT;
|
|
1526
|
+
ALTER TABLE recordings ADD COLUMN audio_sha256 TEXT;
|
|
1527
|
+
ALTER TABLE recordings ADD COLUMN audio_bytes INTEGER;
|
|
1550
1528
|
`
|
|
1551
1529
|
];
|
|
1552
1530
|
var CURRENT_MIGRATION_LEVEL = MIGRATIONS.length - 1;
|
|
@@ -1602,6 +1580,9 @@ function repairSchemaDrift(db) {
|
|
|
1602
1580
|
ensureColumn(db, "recordings", "task_list_id", "TEXT");
|
|
1603
1581
|
ensureColumn(db, "recordings", "machine_id", "TEXT");
|
|
1604
1582
|
ensureColumn(db, "recordings", "metadata", "TEXT DEFAULT '{}'");
|
|
1583
|
+
ensureColumn(db, "recordings", "audio_object_key", "TEXT");
|
|
1584
|
+
ensureColumn(db, "recordings", "audio_sha256", "TEXT");
|
|
1585
|
+
ensureColumn(db, "recordings", "audio_bytes", "INTEGER");
|
|
1605
1586
|
ensureColumn(db, "agents", "active_project_id", "TEXT REFERENCES projects(id) ON DELETE SET NULL");
|
|
1606
1587
|
}
|
|
1607
1588
|
function ensureColumn(db, table, column, definition) {
|
|
@@ -1677,6 +1658,9 @@ function parseRow(row) {
|
|
|
1677
1658
|
return {
|
|
1678
1659
|
id: row["id"],
|
|
1679
1660
|
audio_path: row["audio_path"] || null,
|
|
1661
|
+
audio_object_key: row["audio_object_key"] || null,
|
|
1662
|
+
audio_sha256: row["audio_sha256"] || null,
|
|
1663
|
+
audio_bytes: Number(row["audio_bytes"] ?? 0) || null,
|
|
1680
1664
|
raw_text: row["raw_text"],
|
|
1681
1665
|
processed_text: row["processed_text"] || null,
|
|
1682
1666
|
processing_mode: row["processing_mode"] || "raw",
|
|
@@ -1708,9 +1692,9 @@ function createRecording(input, db, idempotencyKey) {
|
|
|
1708
1692
|
}
|
|
1709
1693
|
const tagsJson = JSON.stringify(input.tags || []);
|
|
1710
1694
|
const metadataJson = JSON.stringify(input.metadata || {});
|
|
1711
|
-
const insertResult = d.query(`INSERT INTO recordings (id, audio_path, raw_text, processed_text, processing_mode, model_used, enhancement_model, duration_ms, language, tags, agent_id, project_id, session_id, goal, role, task_list_id, machine_id, metadata)
|
|
1712
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1713
|
-
ON CONFLICT(id) DO NOTHING`).run(id, input.audio_path || null, input.raw_text, input.processed_text || null, input.processing_mode || "raw", input.model_used || "gpt-4o-transcribe", input.enhancement_model || null, input.duration_ms || 0, input.language || null, tagsJson, input.agent_id || null, input.project_id || null, input.session_id || null, input.goal || null, input.role || null, input.task_list_id || null, input.machine_id || null, metadataJson);
|
|
1695
|
+
const insertResult = d.query(`INSERT INTO recordings (id, audio_path, audio_object_key, audio_sha256, audio_bytes, raw_text, processed_text, processing_mode, model_used, enhancement_model, duration_ms, language, tags, agent_id, project_id, session_id, goal, role, task_list_id, machine_id, metadata)
|
|
1696
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1697
|
+
ON CONFLICT(id) DO NOTHING`).run(id, input.audio_path || null, input.audio_object_key || null, input.audio_sha256 || null, input.audio_bytes || null, input.raw_text, input.processed_text || null, input.processing_mode || "raw", input.model_used || "gpt-4o-transcribe", input.enhancement_model || null, input.duration_ms || 0, input.language || null, tagsJson, input.agent_id || null, input.project_id || null, input.session_id || null, input.goal || null, input.role || null, input.task_list_id || null, input.machine_id || null, metadataJson);
|
|
1714
1698
|
if (insertResult.changes === 0) {
|
|
1715
1699
|
const existing = getRecording(id, d);
|
|
1716
1700
|
if (existing?.id === id)
|
|
@@ -1727,6 +1711,13 @@ function createRecording(input, db, idempotencyKey) {
|
|
|
1727
1711
|
});
|
|
1728
1712
|
return create();
|
|
1729
1713
|
}
|
|
1714
|
+
function updateRecordingAudio(id, audioObjectKey, audioSha256, audioBytes, db) {
|
|
1715
|
+
const d = db || getDatabase();
|
|
1716
|
+
const result = d.query(`UPDATE recordings SET audio_object_key = ?, audio_sha256 = ?, audio_bytes = ? WHERE id = ?`).run(audioObjectKey, audioSha256, audioBytes, id);
|
|
1717
|
+
if (result.changes === 0)
|
|
1718
|
+
return null;
|
|
1719
|
+
return getRecording(id, d);
|
|
1720
|
+
}
|
|
1730
1721
|
function getRecording(id, db) {
|
|
1731
1722
|
const d = db || getDatabase();
|
|
1732
1723
|
let row = d.query("SELECT * FROM recordings WHERE id = ?").get(id);
|
|
@@ -1928,7 +1919,7 @@ function setAgentFocus(idOrName, projectId, db) {
|
|
|
1928
1919
|
// package.json
|
|
1929
1920
|
var package_default = {
|
|
1930
1921
|
name: "@hasna/recordings",
|
|
1931
|
-
version: "0.
|
|
1922
|
+
version: "0.4.0",
|
|
1932
1923
|
type: "module",
|
|
1933
1924
|
description: "Speech-to-text recording tool with MCP and CLI \u2014 records, transcribes, and optionally enhances text using AI",
|
|
1934
1925
|
repository: {
|
|
@@ -1962,10 +1953,10 @@ var package_default = {
|
|
|
1962
1953
|
scripts: {
|
|
1963
1954
|
clean: "rm -rf dist",
|
|
1964
1955
|
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",
|
|
1956
|
+
"build:cli": "bun build src/cli/index.ts --target=bun --outfile=dist/cli/index.js --external=commander --external=chalk --external=openai --external=@aws-sdk/client-s3",
|
|
1957
|
+
"build:mcp": "bun build src/mcp/index.ts --target=bun --outfile=dist/mcp/index.js --external=@modelcontextprotocol/sdk --external=openai --external=@aws-sdk/client-s3",
|
|
1958
|
+
"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 --external=@aws-sdk/client-s3",
|
|
1959
|
+
"build:lib": "bun build src/index.ts src/storage.ts src/sdk/index.ts --target=bun --outdir=dist --external=openai --external=@aws-sdk/client-s3",
|
|
1969
1960
|
"build:native-fs-guard": "/bin/bash scripts/build_native_fs_guard.sh",
|
|
1970
1961
|
"generate:sdk": "bun run scripts/generate-sdk.ts",
|
|
1971
1962
|
migrate: "bun run scripts/migrate.ts",
|
|
@@ -1975,7 +1966,7 @@ var package_default = {
|
|
|
1975
1966
|
"verify:ci-suite": "bun scripts/ci-linux-suite.ts --check",
|
|
1976
1967
|
"verify:ci-suite:run": "bun scripts/ci-linux-suite.ts --verify-run",
|
|
1977
1968
|
"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)',
|
|
1969
|
+
"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:-120000}" $(bun scripts/ci-linux-suite.ts --gated)',
|
|
1979
1970
|
"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
1971
|
"test:vacuity-battery:source": "bun scripts/vacuity-mutation-battery.ts scripts/vacuity-manifests/source-side.tsv",
|
|
1981
1972
|
"test:vacuity-battery:chain": "bun scripts/vacuity-mutation-battery.ts scripts/vacuity-manifests/install-chain.tsv",
|
|
@@ -2012,7 +2003,8 @@ var package_default = {
|
|
|
2012
2003
|
"LICENSE"
|
|
2013
2004
|
],
|
|
2014
2005
|
dependencies: {
|
|
2015
|
-
"@
|
|
2006
|
+
"@aws-sdk/client-s3": "^3.1007.0",
|
|
2007
|
+
"@hasna/contracts": "1.0.2",
|
|
2016
2008
|
"@hasna/events": "0.1.11",
|
|
2017
2009
|
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
2018
2010
|
chalk: "^5.4.1",
|
|
@@ -2022,7 +2014,7 @@ var package_default = {
|
|
|
2022
2014
|
zod: "^3.24.2"
|
|
2023
2015
|
},
|
|
2024
2016
|
devDependencies: {
|
|
2025
|
-
"@types/bun": "
|
|
2017
|
+
"@types/bun": "1.3.14",
|
|
2026
2018
|
"@types/pg": "^8.11.11",
|
|
2027
2019
|
"node-api-headers": "1.9.0",
|
|
2028
2020
|
typescript: "^5.8.2"
|
|
@@ -2046,8 +2038,12 @@ function saveFeedback(input) {
|
|
|
2046
2038
|
|
|
2047
2039
|
// ../contracts/dist/client/transport.js
|
|
2048
2040
|
import { isIP } from "net";
|
|
2049
|
-
import {
|
|
2050
|
-
import {
|
|
2041
|
+
import { spawnSync as spawnSync2 } from "child_process";
|
|
2042
|
+
import { closeSync, fstatSync, openSync, readFileSync as readFileSync2 } from "fs";
|
|
2043
|
+
import { O_NOFOLLOW, O_NONBLOCK, O_RDONLY } from "constants";
|
|
2044
|
+
import { createRequire } from "module";
|
|
2045
|
+
import { hostname as osHostname } from "os";
|
|
2046
|
+
import { isAbsolute as isAbsolute2, join as join4 } from "path";
|
|
2051
2047
|
function envToken(name) {
|
|
2052
2048
|
return name.toUpperCase().replace(/-/g, "_");
|
|
2053
2049
|
}
|
|
@@ -2062,6 +2058,9 @@ function credentialOverrideEnvKey(name) {
|
|
|
2062
2058
|
return `HASNA_${envToken(name)}_API_KEY_OVERRIDE`;
|
|
2063
2059
|
}
|
|
2064
2060
|
var CREDENTIAL_PROFILE_ENV_KEY = "HASNA_PROFILE";
|
|
2061
|
+
function credentialPointerEnvKey(name) {
|
|
2062
|
+
return `HASNA_${envToken(name)}_API_KEY_REF`;
|
|
2063
|
+
}
|
|
2065
2064
|
|
|
2066
2065
|
class CredentialResolutionError extends Error {
|
|
2067
2066
|
appName;
|
|
@@ -2073,34 +2072,70 @@ class CredentialResolutionError extends Error {
|
|
|
2073
2072
|
this.attempted = attempted;
|
|
2074
2073
|
}
|
|
2075
2074
|
}
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2075
|
+
|
|
2076
|
+
class CredentialFileUnsafeError extends Error {
|
|
2077
|
+
path;
|
|
2078
|
+
constructor(path, reason) {
|
|
2079
|
+
super(`Refusing unsafe credential/config file ${path}: ${reason}.`);
|
|
2080
|
+
this.name = "CredentialFileUnsafeError";
|
|
2081
|
+
this.path = path;
|
|
2082
|
+
}
|
|
2083
|
+
}
|
|
2084
|
+
var HASNA_HOME_ENV_KEY = "HASNA_HOME";
|
|
2085
|
+
var HASNA_CONFIG_HOME_ENV_KEY = "HASNA_CONFIG_HOME";
|
|
2086
|
+
var KEYCHAIN_STATION_ENV_KEY = "HASNA_STATION";
|
|
2087
|
+
var HASNA_HOME_DIR = ".hasna";
|
|
2088
|
+
var CONFIG_SUBDIR = "config";
|
|
2089
|
+
var CREDENTIALS_FILE = "credentials";
|
|
2090
|
+
var KEYCHAIN_SECURITY_BIN = "/usr/bin/security";
|
|
2091
|
+
var KEYCHAIN_SERVICE_PREFIX = "hasna.credentials";
|
|
2092
|
+
var KEYCHAIN_ITEM_NOT_FOUND_STATUS = 44;
|
|
2093
|
+
var KEYCHAIN_SPAWN_TIMEOUT_MS = 1e4;
|
|
2080
2094
|
var MAX_CREDENTIAL_FILE_BYTES = 64 * 1024;
|
|
2081
2095
|
var SAFE_APP_SLUG = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
|
|
2082
2096
|
var SAFE_PROFILE = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/;
|
|
2083
2097
|
var ILLEGAL_IN_HEADER_VALUE = /[^\t\x20-\x7e]/;
|
|
2098
|
+
var VAULT_POINTER_SHAPE = /^[a-z0-9][a-z0-9-]*(?:\/[a-z0-9][a-z0-9-_.]*){2,}$/;
|
|
2084
2099
|
function homeDir(env) {
|
|
2085
2100
|
const home = env.HOME?.trim();
|
|
2086
2101
|
return home ? home : null;
|
|
2087
2102
|
}
|
|
2088
|
-
function
|
|
2089
|
-
|
|
2103
|
+
function absoluteOverride(env, key) {
|
|
2104
|
+
const value = env[key]?.trim();
|
|
2105
|
+
return value && isAbsolute2(value) ? value : null;
|
|
2090
2106
|
}
|
|
2091
|
-
function
|
|
2107
|
+
function hasnaHomeDir(env) {
|
|
2108
|
+
const override = absoluteOverride(env, HASNA_HOME_ENV_KEY);
|
|
2109
|
+
if (override)
|
|
2110
|
+
return override;
|
|
2092
2111
|
const home = homeDir(env);
|
|
2093
|
-
|
|
2112
|
+
return home ? join4(home, HASNA_HOME_DIR) : null;
|
|
2113
|
+
}
|
|
2114
|
+
function appConfigDir(name, env) {
|
|
2115
|
+
const configRoot = absoluteOverride(env, HASNA_CONFIG_HOME_ENV_KEY);
|
|
2116
|
+
if (configRoot)
|
|
2117
|
+
return join4(configRoot, name);
|
|
2118
|
+
const root = hasnaHomeDir(env);
|
|
2119
|
+
return root ? join4(root, name, CONFIG_SUBDIR) : null;
|
|
2120
|
+
}
|
|
2121
|
+
function credentialDiskSourceList(name, env, profile = null) {
|
|
2122
|
+
if (!SAFE_APP_SLUG.test(name))
|
|
2094
2123
|
return [];
|
|
2095
|
-
const
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2124
|
+
const directory = appConfigDir(name, env);
|
|
2125
|
+
if (!directory)
|
|
2126
|
+
return [];
|
|
2127
|
+
const file = profile ? `${CREDENTIALS_FILE}-${profile}` : CREDENTIALS_FILE;
|
|
2128
|
+
return [{ path: join4(directory, file), tier: "disk" }];
|
|
2129
|
+
}
|
|
2130
|
+
function credentialDiskSources(name, env) {
|
|
2131
|
+
return credentialDiskSourceList(name, env, null).map((s) => s.path);
|
|
2132
|
+
}
|
|
2133
|
+
function profileDiskSources(name, env, profile) {
|
|
2134
|
+
return credentialDiskSourceList(name, env, profile).map((s) => s.path);
|
|
2101
2135
|
}
|
|
2102
2136
|
function parseEnvFile(text) {
|
|
2103
2137
|
const values = new Map;
|
|
2138
|
+
const unusable = new Set;
|
|
2104
2139
|
for (const rawLine of text.split(/\r?\n/)) {
|
|
2105
2140
|
const line = rawLine.trim();
|
|
2106
2141
|
if (line.length === 0 || line.startsWith("#"))
|
|
@@ -2115,38 +2150,81 @@ function parseEnvFile(text) {
|
|
|
2115
2150
|
let value = withoutExport.slice(equals + 1).trim();
|
|
2116
2151
|
const quote = value[0];
|
|
2117
2152
|
if (quote === '"' || quote === "'") {
|
|
2118
|
-
if (value.length < 2 || !value.endsWith(quote))
|
|
2153
|
+
if (value.length < 2 || !value.endsWith(quote)) {
|
|
2154
|
+
unusable.add(key);
|
|
2119
2155
|
continue;
|
|
2156
|
+
}
|
|
2120
2157
|
value = value.slice(1, -1);
|
|
2121
2158
|
}
|
|
2122
|
-
if (value.length === 0)
|
|
2159
|
+
if (value.trim().length === 0) {
|
|
2160
|
+
unusable.add(key);
|
|
2123
2161
|
continue;
|
|
2162
|
+
}
|
|
2163
|
+
if (values.has(key) && values.get(key) !== value)
|
|
2164
|
+
unusable.add(key);
|
|
2124
2165
|
values.set(key, value);
|
|
2125
2166
|
}
|
|
2126
|
-
return values;
|
|
2167
|
+
return { values, unusable };
|
|
2168
|
+
}
|
|
2169
|
+
function configFileModeAllowed(mode) {
|
|
2170
|
+
const permissions = mode & 4095;
|
|
2171
|
+
return permissions === 256 || permissions === 384;
|
|
2172
|
+
}
|
|
2173
|
+
function configFileReadsCoherent(before, after) {
|
|
2174
|
+
return before.dev === after.dev && before.ino === after.ino && before.size === after.size && before.mtimeMs === after.mtimeMs && before.ctimeMs === after.ctimeMs;
|
|
2127
2175
|
}
|
|
2128
2176
|
function readAppConfigFile(path) {
|
|
2129
|
-
|
|
2177
|
+
const unsafe = (reason) => {
|
|
2178
|
+
throw new CredentialFileUnsafeError(path, reason);
|
|
2179
|
+
};
|
|
2180
|
+
let fd = -1;
|
|
2130
2181
|
try {
|
|
2131
|
-
|
|
2132
|
-
|
|
2182
|
+
fd = openSync(path, O_RDONLY | O_NOFOLLOW | O_NONBLOCK);
|
|
2183
|
+
} catch (error) {
|
|
2184
|
+
const code = error.code;
|
|
2185
|
+
if (code === "ENOENT" || code === "ENOTDIR")
|
|
2133
2186
|
return null;
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2187
|
+
if (code === "ELOOP")
|
|
2188
|
+
unsafe("the path is a symlink");
|
|
2189
|
+
unsafe(`the path could not be opened (${code ?? "unknown error"})`);
|
|
2190
|
+
}
|
|
2191
|
+
try {
|
|
2192
|
+
const before = fstatSync(fd);
|
|
2193
|
+
if (!before.isFile())
|
|
2194
|
+
unsafe("the path is not a regular file");
|
|
2195
|
+
if (!configFileModeAllowed(before.mode)) {
|
|
2196
|
+
unsafe(`permission mode ${(before.mode & 4095).toString(8).padStart(4, "0")} is not owner-only 0400 or 0600`);
|
|
2197
|
+
}
|
|
2198
|
+
const uid = process.getuid?.() ?? process.geteuid?.();
|
|
2199
|
+
if (uid !== undefined && before.uid !== uid)
|
|
2200
|
+
unsafe("the file is not owned by the current user");
|
|
2201
|
+
if (before.size > MAX_CREDENTIAL_FILE_BYTES)
|
|
2202
|
+
unsafe("the file exceeds the size limit");
|
|
2203
|
+
const bytes = readFileSync2(fd);
|
|
2204
|
+
const after = fstatSync(fd);
|
|
2205
|
+
if (!configFileReadsCoherent(before, after)) {
|
|
2206
|
+
unsafe("the file changed while being read");
|
|
2207
|
+
}
|
|
2208
|
+
return parseEnvFile(bytes.toString("utf8"));
|
|
2209
|
+
} finally {
|
|
2210
|
+
if (fd !== -1)
|
|
2211
|
+
closeSync(fd);
|
|
2137
2212
|
}
|
|
2138
|
-
return parseEnvFile(text);
|
|
2139
2213
|
}
|
|
2140
2214
|
function readCredentialFile(path, apiKeyKeys) {
|
|
2141
|
-
const
|
|
2142
|
-
if (!
|
|
2215
|
+
const parsed = readAppConfigFile(path);
|
|
2216
|
+
if (!parsed)
|
|
2143
2217
|
return null;
|
|
2144
2218
|
for (const key of apiKeyKeys) {
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2219
|
+
if (parsed.unusable.has(key)) {
|
|
2220
|
+
throw new CredentialFileUnsafeError(path, `${key} is declared but blank or malformed`);
|
|
2221
|
+
}
|
|
2148
2222
|
}
|
|
2149
|
-
|
|
2223
|
+
const values = apiKeyKeys.map((key) => parsed.values.get(key)?.trim()).filter((value) => Boolean(value));
|
|
2224
|
+
if (new Set(values).size > 1) {
|
|
2225
|
+
throw new CredentialFileUnsafeError(path, "credential aliases disagree");
|
|
2226
|
+
}
|
|
2227
|
+
return values[0] ?? null;
|
|
2150
2228
|
}
|
|
2151
2229
|
var CREDENTIAL_SHAPED_KEY = /(?:^|_)(?:API_KEY|KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|AUTH)(?:_|$)/;
|
|
2152
2230
|
function appConfigDiskValue(name, env, keys) {
|
|
@@ -2154,11 +2232,19 @@ function appConfigDiskValue(name, env, keys) {
|
|
|
2154
2232
|
if (wanted.length === 0)
|
|
2155
2233
|
return null;
|
|
2156
2234
|
for (const path of credentialDiskSources(name, env)) {
|
|
2157
|
-
const
|
|
2158
|
-
if (!
|
|
2235
|
+
const parsed = readAppConfigFile(path);
|
|
2236
|
+
if (!parsed)
|
|
2159
2237
|
continue;
|
|
2238
|
+
if (wanted.some((key) => parsed.unusable.has(key))) {
|
|
2239
|
+
return { key: wanted.find((key) => parsed.unusable.has(key)), value: "", path, unusable: true };
|
|
2240
|
+
}
|
|
2241
|
+
const values = wanted.map((key) => parsed.values.get(key)?.trim()).filter((value) => Boolean(value));
|
|
2242
|
+
if (new Set(values).size > 1)
|
|
2243
|
+
throw new CredentialFileUnsafeError(path, "configuration aliases disagree");
|
|
2160
2244
|
for (const key of wanted) {
|
|
2161
|
-
|
|
2245
|
+
if (parsed.unusable.has(key))
|
|
2246
|
+
return { key, value: "", path, unusable: true };
|
|
2247
|
+
const value = parsed.values.get(key)?.trim();
|
|
2162
2248
|
if (value)
|
|
2163
2249
|
return { key, value, path };
|
|
2164
2250
|
}
|
|
@@ -2166,20 +2252,21 @@ function appConfigDiskValue(name, env, keys) {
|
|
|
2166
2252
|
return null;
|
|
2167
2253
|
}
|
|
2168
2254
|
function assertUsableCredential(appName, source, value) {
|
|
2255
|
+
if (VAULT_POINTER_SHAPE.test(value)) {
|
|
2256
|
+
throw new CredentialResolutionError(appName, `The credential from ${source} looks like a secrets-vault pointer (a path-shaped reference like ` + `'namespace/app/live/api_key'). A vault path is NEVER accepted as a literal API key. ` + `Use ${credentialPointerEnvKey(appName)} to resolve the key through the vault, or provide the actual key value.`, [source]);
|
|
2257
|
+
}
|
|
2169
2258
|
if (!ILLEGAL_IN_HEADER_VALUE.test(value))
|
|
2170
2259
|
return;
|
|
2171
2260
|
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
2261
|
}
|
|
2173
2262
|
var INSPECT_CUSTOM = Symbol.for("nodejs.util.inspect.custom");
|
|
2174
2263
|
var CREDENTIAL_SEAL = Symbol.for("hasna:contracts:sealedCredential");
|
|
2175
|
-
var CALLER_SUPPLIED_CREDENTIAL_PROVIDER_SOURCE = "caller-supplied CredentialProvider";
|
|
2176
2264
|
function sealCredential(fields) {
|
|
2177
2265
|
const { apiKey } = fields;
|
|
2178
2266
|
const visible = {
|
|
2179
2267
|
tier: fields.tier,
|
|
2180
2268
|
source: fields.source,
|
|
2181
2269
|
deliberate: fields.deliberate,
|
|
2182
|
-
deprecated: fields.deprecated,
|
|
2183
2270
|
diskCandidates: Object.freeze([...fields.diskCandidates]),
|
|
2184
2271
|
warning: fields.warning
|
|
2185
2272
|
};
|
|
@@ -2190,6 +2277,14 @@ function sealCredential(fields) {
|
|
|
2190
2277
|
writable: false,
|
|
2191
2278
|
configurable: false
|
|
2192
2279
|
});
|
|
2280
|
+
if (fields.pointerVaultKey !== undefined) {
|
|
2281
|
+
Object.defineProperty(sealed, "pointerVaultKey", {
|
|
2282
|
+
value: fields.pointerVaultKey,
|
|
2283
|
+
enumerable: false,
|
|
2284
|
+
writable: false,
|
|
2285
|
+
configurable: false
|
|
2286
|
+
});
|
|
2287
|
+
}
|
|
2193
2288
|
Object.defineProperty(sealed, INSPECT_CUSTOM, {
|
|
2194
2289
|
value: () => ({ ...visible, apiKey: "[redacted]" }),
|
|
2195
2290
|
enumerable: false,
|
|
@@ -2204,88 +2299,142 @@ function sealCredential(fields) {
|
|
|
2204
2299
|
});
|
|
2205
2300
|
return Object.freeze(sealed);
|
|
2206
2301
|
}
|
|
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
|
-
});
|
|
2222
|
-
}
|
|
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
2302
|
function firstEnvValue(env, keys) {
|
|
2248
2303
|
for (const key of keys) {
|
|
2304
|
+
if (!Object.prototype.hasOwnProperty.call(env, key))
|
|
2305
|
+
continue;
|
|
2249
2306
|
const value = env[key]?.trim();
|
|
2250
2307
|
if (value)
|
|
2251
2308
|
return { key, value };
|
|
2252
2309
|
}
|
|
2253
2310
|
return null;
|
|
2254
2311
|
}
|
|
2255
|
-
var
|
|
2256
|
-
function
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
}
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2312
|
+
var AMBIENT_ENVIRONMENT = Symbol.for("hasna:contracts:ambientClientEnvironment");
|
|
2313
|
+
function isAmbientEnvironment(env) {
|
|
2314
|
+
return env === process.env || env[AMBIENT_ENVIRONMENT] === true;
|
|
2315
|
+
}
|
|
2316
|
+
function defaultKeychainRunner(argv) {
|
|
2317
|
+
const result = spawnSync2(KEYCHAIN_SECURITY_BIN, [...argv], {
|
|
2318
|
+
encoding: "utf8",
|
|
2319
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
2320
|
+
timeout: KEYCHAIN_SPAWN_TIMEOUT_MS
|
|
2321
|
+
});
|
|
2322
|
+
return {
|
|
2323
|
+
status: result.status,
|
|
2324
|
+
stdout: result.stdout ?? "",
|
|
2325
|
+
stderr: result.error ? result.error.message : result.stderr ?? ""
|
|
2326
|
+
};
|
|
2327
|
+
}
|
|
2328
|
+
function keychainTierEnabled(env, options) {
|
|
2329
|
+
if ((options.platform ?? process.platform) !== "darwin")
|
|
2330
|
+
return false;
|
|
2331
|
+
if (options.enabled !== undefined)
|
|
2332
|
+
return options.enabled;
|
|
2333
|
+
return options.run !== undefined || isAmbientEnvironment(env);
|
|
2334
|
+
}
|
|
2335
|
+
function keychainAccount(env, options) {
|
|
2336
|
+
const station = env[KEYCHAIN_STATION_ENV_KEY]?.trim();
|
|
2337
|
+
if (station)
|
|
2338
|
+
return station;
|
|
2339
|
+
const host = (options.hostname ?? osHostname)().split(".")[0]?.trim() ?? "";
|
|
2340
|
+
if (host)
|
|
2341
|
+
return host;
|
|
2342
|
+
const user = env.USER?.trim();
|
|
2343
|
+
return user || null;
|
|
2344
|
+
}
|
|
2345
|
+
function keychainFailureHint(text) {
|
|
2346
|
+
const line = text.split(/\r?\n/).find((entry) => entry.trim().length > 0)?.trim() ?? "";
|
|
2347
|
+
const clean = line.replace(/[\u0000-\u001f\u007f]/g, "").slice(0, 200);
|
|
2348
|
+
return clean ? `: ${clean}` : "";
|
|
2349
|
+
}
|
|
2350
|
+
function readKeychainItem(name, env, kind, options) {
|
|
2351
|
+
if (!SAFE_APP_SLUG.test(name) || !keychainTierEnabled(env, options))
|
|
2352
|
+
return null;
|
|
2353
|
+
const account = keychainAccount(env, options);
|
|
2354
|
+
if (!account)
|
|
2355
|
+
return null;
|
|
2356
|
+
const service = `${KEYCHAIN_SERVICE_PREFIX}.${name}.${kind}`;
|
|
2357
|
+
const source = `keychain:${service}@${account}`;
|
|
2358
|
+
const run = options.run ?? defaultKeychainRunner;
|
|
2359
|
+
let result;
|
|
2360
|
+
try {
|
|
2361
|
+
result = run(["find-generic-password", "-a", account, "-s", service, "-w"]);
|
|
2362
|
+
} catch (error) {
|
|
2363
|
+
const reason = keychainFailureHint(error instanceof Error ? error.message : String(error));
|
|
2364
|
+
throw new CredentialResolutionError(name, `The Keychain lookup for ${source} could not run${reason}. A Keychain failure is never resolved ` + `around: fix the keychain, or delete the item to fall through to the credential on disk.`, [source]);
|
|
2365
|
+
}
|
|
2366
|
+
if (result.status === KEYCHAIN_ITEM_NOT_FOUND_STATUS)
|
|
2367
|
+
return null;
|
|
2368
|
+
if (result.status !== 0) {
|
|
2369
|
+
throw new CredentialResolutionError(name, `The Keychain lookup for ${source} failed (security exited ` + `${result.status ?? "without a status"}${keychainFailureHint(result.stderr)}). A Keychain item that ` + `exists but cannot be read is never resolved around: unlock the keychain, run from a session that ` + `may use it, or delete the item to fall through to the credential on disk.`, [source]);
|
|
2269
2370
|
}
|
|
2371
|
+
const value = result.stdout.trim();
|
|
2372
|
+
if (!value) {
|
|
2373
|
+
throw new CredentialResolutionError(name, `${source} exists but holds an empty value; a declared item never falls through to another ` + `identity. Store a value in it or delete the item.`, [source]);
|
|
2374
|
+
}
|
|
2375
|
+
return { value, source };
|
|
2376
|
+
}
|
|
2377
|
+
function keychainConfigValue(name, env, options = {}) {
|
|
2378
|
+
return readKeychainItem(name, env, "api-url", options);
|
|
2379
|
+
}
|
|
2380
|
+
function snapshotClientEnvironment(name, env) {
|
|
2381
|
+
const keys = clientTransportEnvKeys(name);
|
|
2382
|
+
const ambient = isAmbientEnvironment(env);
|
|
2383
|
+
const snapshot = Object.create(null);
|
|
2384
|
+
for (const key of [
|
|
2385
|
+
...keys.apiUrlKeys,
|
|
2386
|
+
...keys.apiKeyKeys,
|
|
2387
|
+
credentialOverrideEnvKey(name),
|
|
2388
|
+
credentialPointerEnvKey(name),
|
|
2389
|
+
CREDENTIAL_PROFILE_ENV_KEY,
|
|
2390
|
+
"HOME",
|
|
2391
|
+
HASNA_HOME_ENV_KEY,
|
|
2392
|
+
HASNA_CONFIG_HOME_ENV_KEY,
|
|
2393
|
+
KEYCHAIN_STATION_ENV_KEY,
|
|
2394
|
+
"USER"
|
|
2395
|
+
]) {
|
|
2396
|
+
const descriptor = Object.getOwnPropertyDescriptor(env, key);
|
|
2397
|
+
if (!descriptor)
|
|
2398
|
+
continue;
|
|
2399
|
+
if (!("value" in descriptor)) {
|
|
2400
|
+
throw new CredentialResolutionError(name, `${key} is accessor-backed; client configuration requires own data properties.`, [key]);
|
|
2401
|
+
}
|
|
2402
|
+
if (descriptor.value !== undefined && typeof descriptor.value !== "string") {
|
|
2403
|
+
throw new CredentialResolutionError(name, `${key} must be a string data property.`, [key]);
|
|
2404
|
+
}
|
|
2405
|
+
snapshot[key] = descriptor.value;
|
|
2406
|
+
}
|
|
2407
|
+
if (ambient) {
|
|
2408
|
+
Object.defineProperty(snapshot, AMBIENT_ENVIRONMENT, {
|
|
2409
|
+
value: true,
|
|
2410
|
+
enumerable: false,
|
|
2411
|
+
writable: false,
|
|
2412
|
+
configurable: false
|
|
2413
|
+
});
|
|
2414
|
+
}
|
|
2415
|
+
return Object.freeze(snapshot);
|
|
2270
2416
|
}
|
|
2271
2417
|
function resolveCredential(name, env, options = {}) {
|
|
2418
|
+
env = snapshotClientEnvironment(name, env);
|
|
2272
2419
|
const { apiKeyKeys } = clientTransportEnvKeys(name);
|
|
2273
2420
|
const diskPaths = credentialDiskSources(name, env);
|
|
2274
|
-
|
|
2275
|
-
|
|
2421
|
+
if (options.apiKey !== undefined) {
|
|
2422
|
+
const explicitKey = options.apiKey.trim();
|
|
2423
|
+
if (!explicitKey) {
|
|
2424
|
+
throw new CredentialResolutionError(name, "The explicit apiKey argument is blank; an explicit credential never falls through to another identity.", ["explicit apiKey argument"]);
|
|
2425
|
+
}
|
|
2276
2426
|
assertUsableCredential(name, "the explicit apiKey argument", explicitKey);
|
|
2277
2427
|
return sealCredential({
|
|
2278
2428
|
apiKey: explicitKey,
|
|
2279
2429
|
tier: "argument",
|
|
2280
2430
|
source: "explicit apiKey argument",
|
|
2281
2431
|
deliberate: true,
|
|
2282
|
-
deprecated: false,
|
|
2283
2432
|
diskCandidates: diskPaths,
|
|
2284
2433
|
warning: null
|
|
2285
2434
|
});
|
|
2286
2435
|
}
|
|
2287
2436
|
const overrideKeyName = credentialOverrideEnvKey(name);
|
|
2288
|
-
const overrideRaw = env[overrideKeyName];
|
|
2437
|
+
const overrideRaw = Object.prototype.hasOwnProperty.call(env, overrideKeyName) ? env[overrideKeyName] : undefined;
|
|
2289
2438
|
if (overrideRaw !== undefined) {
|
|
2290
2439
|
const override = overrideRaw.trim();
|
|
2291
2440
|
if (!override) {
|
|
@@ -2297,12 +2446,38 @@ function resolveCredential(name, env, options = {}) {
|
|
|
2297
2446
|
tier: "override",
|
|
2298
2447
|
source: overrideKeyName,
|
|
2299
2448
|
deliberate: true,
|
|
2300
|
-
deprecated: false,
|
|
2301
2449
|
diskCandidates: diskPaths,
|
|
2302
2450
|
warning: null
|
|
2303
2451
|
});
|
|
2304
2452
|
}
|
|
2305
|
-
const
|
|
2453
|
+
const pointerKeyName = credentialPointerEnvKey(name);
|
|
2454
|
+
const pointerRaw = Object.prototype.hasOwnProperty.call(env, pointerKeyName) ? env[pointerKeyName] : undefined;
|
|
2455
|
+
if (pointerRaw !== undefined) {
|
|
2456
|
+
const pointer = pointerRaw.trim();
|
|
2457
|
+
if (!pointer) {
|
|
2458
|
+
throw new CredentialResolutionError(name, `${pointerKeyName} is set but empty. It is a deliberate vault pointer, so it is not resolved around: ` + `either give it a vault item key or unset it to fall back to the credential on disk.`, [pointerKeyName]);
|
|
2459
|
+
}
|
|
2460
|
+
if (!VAULT_POINTER_SHAPE.test(pointer)) {
|
|
2461
|
+
throw new CredentialResolutionError(name, `${pointerKeyName} must name a vault ITEM KEY (a path-shaped reference like ` + `'namespace/app/live/api_key'), not a credential value. A pointer that carries a literal is refused.`, [pointerKeyName]);
|
|
2462
|
+
}
|
|
2463
|
+
return sealCredential({
|
|
2464
|
+
apiKey: "",
|
|
2465
|
+
pointerVaultKey: pointer,
|
|
2466
|
+
tier: "pointer",
|
|
2467
|
+
source: pointerKeyName,
|
|
2468
|
+
deliberate: true,
|
|
2469
|
+
diskCandidates: diskPaths,
|
|
2470
|
+
warning: null
|
|
2471
|
+
});
|
|
2472
|
+
}
|
|
2473
|
+
if (options.profile !== undefined && !options.profile.trim()) {
|
|
2474
|
+
throw new CredentialResolutionError(name, "The explicit profile argument is blank; an explicit identity selection never falls through.", ["explicit profile argument"]);
|
|
2475
|
+
}
|
|
2476
|
+
const profileRaw = Object.prototype.hasOwnProperty.call(env, CREDENTIAL_PROFILE_ENV_KEY) ? env[CREDENTIAL_PROFILE_ENV_KEY] : undefined;
|
|
2477
|
+
if (profileRaw !== undefined && !profileRaw.trim()) {
|
|
2478
|
+
throw new CredentialResolutionError(name, `${CREDENTIAL_PROFILE_ENV_KEY} is set but blank.`, [CREDENTIAL_PROFILE_ENV_KEY]);
|
|
2479
|
+
}
|
|
2480
|
+
const profile = options.profile?.trim() || profileRaw?.trim();
|
|
2306
2481
|
if (profile) {
|
|
2307
2482
|
const profileSource = options.profile?.trim() ? "explicit profile argument" : CREDENTIAL_PROFILE_ENV_KEY;
|
|
2308
2483
|
if (!SAFE_PROFILE.test(profile)) {
|
|
@@ -2318,7 +2493,6 @@ function resolveCredential(name, env, options = {}) {
|
|
|
2318
2493
|
tier: "profile",
|
|
2319
2494
|
source: path,
|
|
2320
2495
|
deliberate: true,
|
|
2321
|
-
deprecated: false,
|
|
2322
2496
|
diskCandidates: paths,
|
|
2323
2497
|
warning: null
|
|
2324
2498
|
});
|
|
@@ -2326,51 +2500,67 @@ function resolveCredential(name, env, options = {}) {
|
|
|
2326
2500
|
}
|
|
2327
2501
|
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
2502
|
}
|
|
2329
|
-
const
|
|
2503
|
+
const definedEnvEntries = apiKeyKeys.filter((key) => Object.prototype.hasOwnProperty.call(env, key) && env[key] !== undefined).map((key) => ({ key, value: String(env[key]).trim() }));
|
|
2504
|
+
const blankEnv = definedEnvEntries.find((entry) => entry.value.length === 0);
|
|
2505
|
+
if (blankEnv) {
|
|
2506
|
+
throw new CredentialResolutionError(name, `${blankEnv.key} is set but blank; a declared credential never falls through to another alias or identity.`, [blankEnv.key]);
|
|
2507
|
+
}
|
|
2508
|
+
if (definedEnvEntries.length > 1 && new Set(definedEnvEntries.map((entry) => entry.value)).size > 1) {
|
|
2509
|
+
throw new CredentialResolutionError(name, `${definedEnvEntries.map((entry) => entry.key).join(" and ")} disagree; credential aliases must be identical or only one may be set.`, definedEnvEntries.map((entry) => entry.key));
|
|
2510
|
+
}
|
|
2511
|
+
const envHit = firstEnvValue(env, apiKeyKeys);
|
|
2512
|
+
const keychainHit = readKeychainItem(name, env, "api-key", options.keychain ?? {});
|
|
2513
|
+
if (keychainHit) {
|
|
2514
|
+
assertUsableCredential(name, keychainHit.source, keychainHit.value);
|
|
2515
|
+
const warning = envHit && envHit.value !== keychainHit.value ? `Credential sources disagree for '${name}': ${keychainHit.source} and ${envHit.key} hold ` + `different keys. ${keychainHit.source} wins, because the Keychain 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;
|
|
2516
|
+
return sealCredential({
|
|
2517
|
+
apiKey: keychainHit.value,
|
|
2518
|
+
tier: "keychain",
|
|
2519
|
+
source: keychainHit.source,
|
|
2520
|
+
deliberate: false,
|
|
2521
|
+
diskCandidates: diskPaths,
|
|
2522
|
+
warning
|
|
2523
|
+
});
|
|
2524
|
+
}
|
|
2525
|
+
const diskSourceList = credentialDiskSourceList(name, env, null);
|
|
2526
|
+
const diskHits = diskSourceList.map((src) => ({ src, value: readCredentialFile(src.path, apiKeyKeys) })).filter((hit) => hit.value !== null);
|
|
2330
2527
|
if (diskHits.length > 0) {
|
|
2331
2528
|
const winner = diskHits[0];
|
|
2332
|
-
assertUsableCredential(name, winner.path, winner.value);
|
|
2529
|
+
assertUsableCredential(name, winner.src.path, winner.value);
|
|
2333
2530
|
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
|
-
})()
|
|
2531
|
+
...diskHits.slice(1).filter((hit) => hit.value !== winner.value).map((hit) => hit.src.path),
|
|
2532
|
+
...envHit && envHit.value !== winner.value ? [envHit.key] : []
|
|
2339
2533
|
];
|
|
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;
|
|
2534
|
+
const warning = divergentSources.length > 0 ? `Credential sources disagree for '${name}': ${winner.src.path} and ` + `${divergentSources.join(", ")} hold different keys. ${winner.src.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
2535
|
return sealCredential({
|
|
2342
2536
|
apiKey: winner.value,
|
|
2343
|
-
tier:
|
|
2344
|
-
source: winner.path,
|
|
2537
|
+
tier: winner.src.tier,
|
|
2538
|
+
source: winner.src.path,
|
|
2345
2539
|
deliberate: false,
|
|
2346
|
-
deprecated: false,
|
|
2347
2540
|
diskCandidates: diskPaths,
|
|
2348
2541
|
warning
|
|
2349
2542
|
});
|
|
2350
2543
|
}
|
|
2351
|
-
|
|
2352
|
-
|
|
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);
|
|
2361
|
-
}
|
|
2544
|
+
if (envHit) {
|
|
2545
|
+
assertUsableCredential(name, envHit.key, envHit.value);
|
|
2362
2546
|
return sealCredential({
|
|
2363
|
-
apiKey:
|
|
2364
|
-
tier: "
|
|
2365
|
-
source:
|
|
2547
|
+
apiKey: envHit.value,
|
|
2548
|
+
tier: "env",
|
|
2549
|
+
source: envHit.key,
|
|
2366
2550
|
deliberate: false,
|
|
2367
|
-
deprecated: true,
|
|
2368
2551
|
diskCandidates: diskPaths,
|
|
2369
|
-
warning:
|
|
2552
|
+
warning: null
|
|
2370
2553
|
});
|
|
2371
2554
|
}
|
|
2372
2555
|
return null;
|
|
2373
2556
|
}
|
|
2557
|
+
var SECRETS_PACKAGE_SPECIFIER = "@hasna/" + "secrets";
|
|
2558
|
+
var requireSecretsSdk = createRequire(import.meta.url);
|
|
2559
|
+
var DEFAULT_FLEET_GATEWAY_ORIGIN = "https://api.hasna.com";
|
|
2560
|
+
var DEFAULT_AUTHORITY_SOURCE = "default";
|
|
2561
|
+
function defaultFleetGatewayBaseUrl(name) {
|
|
2562
|
+
return `${DEFAULT_FLEET_GATEWAY_ORIGIN}/${validateAppSlug(name)}`;
|
|
2563
|
+
}
|
|
2374
2564
|
var ASCII_CONTROL_PATTERN = /[\u0000-\u001f\u007f]/;
|
|
2375
2565
|
var DNS_LABEL_PATTERN = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
|
|
2376
2566
|
function isValidDnsDomain(value) {
|
|
@@ -2379,23 +2569,835 @@ function isValidDnsDomain(value) {
|
|
|
2379
2569
|
}
|
|
2380
2570
|
return value.split(".").every((label) => label.length <= 63 && !label.startsWith("xn--") && DNS_LABEL_PATTERN.test(label));
|
|
2381
2571
|
}
|
|
2382
|
-
function
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
2572
|
+
function validateAppSlug(name) {
|
|
2573
|
+
if (name.length > 63 || !DNS_LABEL_PATTERN.test(name)) {
|
|
2574
|
+
throw new Error("App name must be one lowercase DNS label.");
|
|
2575
|
+
}
|
|
2576
|
+
return name;
|
|
2577
|
+
}
|
|
2578
|
+
function rawAuthority(value) {
|
|
2579
|
+
const match = /^[a-z][a-z0-9+.-]*:\/\//i.exec(value);
|
|
2580
|
+
if (!match)
|
|
2581
|
+
throw new Error("API URL must be absolute.");
|
|
2582
|
+
const afterScheme = value.slice(match[0].length);
|
|
2583
|
+
const boundary = afterScheme.search(/[/?#]/);
|
|
2584
|
+
const authority = boundary === -1 ? afterScheme : afterScheme.slice(0, boundary);
|
|
2585
|
+
if (!authority)
|
|
2586
|
+
throw new Error("API URL must include a hostname.");
|
|
2587
|
+
return authority;
|
|
2588
|
+
}
|
|
2589
|
+
function assertCanonicalPort(port) {
|
|
2590
|
+
if (!/^[0-9]+$/.test(port) || port.length > 1 && port.startsWith("0")) {
|
|
2591
|
+
throw new Error("API URL authority must contain a canonical port between 1 and 65535.");
|
|
2592
|
+
}
|
|
2593
|
+
const numericPort = Number(port);
|
|
2594
|
+
if (!Number.isSafeInteger(numericPort) || numericPort < 1 || numericPort > 65535) {
|
|
2595
|
+
throw new Error("API URL authority must contain a canonical port between 1 and 65535.");
|
|
2596
|
+
}
|
|
2597
|
+
}
|
|
2598
|
+
function canonicalAuthorityHostname(authority) {
|
|
2599
|
+
let rawHostname;
|
|
2600
|
+
if (authority.startsWith("[")) {
|
|
2601
|
+
const closingBracket = authority.indexOf("]");
|
|
2602
|
+
if (closingBracket === -1) {
|
|
2603
|
+
throw new Error("API URL authority must contain a canonical hostname.");
|
|
2604
|
+
}
|
|
2605
|
+
rawHostname = authority.slice(0, closingBracket + 1);
|
|
2606
|
+
const portSuffix = authority.slice(closingBracket + 1);
|
|
2607
|
+
if (portSuffix) {
|
|
2608
|
+
if (!portSuffix.startsWith(":")) {
|
|
2609
|
+
throw new Error("API URL authority must contain a canonical hostname and port.");
|
|
2610
|
+
}
|
|
2611
|
+
assertCanonicalPort(portSuffix.slice(1));
|
|
2612
|
+
}
|
|
2613
|
+
if (isIP(rawHostname.slice(1, -1)) !== 6) {
|
|
2614
|
+
throw new Error("API URL authority must contain a canonical IPv6 literal.");
|
|
2615
|
+
}
|
|
2616
|
+
} else {
|
|
2617
|
+
const firstColon = authority.indexOf(":");
|
|
2618
|
+
const lastColon = authority.lastIndexOf(":");
|
|
2619
|
+
if (firstColon !== lastColon) {
|
|
2620
|
+
throw new Error("IPv6 API URL authorities must use brackets.");
|
|
2621
|
+
}
|
|
2622
|
+
if (lastColon !== -1) {
|
|
2623
|
+
const port = authority.slice(lastColon + 1);
|
|
2624
|
+
assertCanonicalPort(port);
|
|
2625
|
+
rawHostname = authority.slice(0, lastColon);
|
|
2626
|
+
} else {
|
|
2627
|
+
rawHostname = authority;
|
|
2628
|
+
}
|
|
2629
|
+
const ipVersion = isIP(rawHostname);
|
|
2630
|
+
const numericAddressParts = rawHostname.split(".");
|
|
2631
|
+
const looksLikeNonCanonicalIpv4 = numericAddressParts.every((part) => /^(?:0x[0-9a-f]+|[0-9]+)$/i.test(part));
|
|
2632
|
+
if (ipVersion !== 4 && looksLikeNonCanonicalIpv4 || ipVersion !== 4 && !isValidDnsDomain(rawHostname.toLowerCase())) {
|
|
2633
|
+
throw new Error("API URL authority must contain a canonical ASCII hostname.");
|
|
2634
|
+
}
|
|
2635
|
+
}
|
|
2636
|
+
return rawHostname.toLowerCase();
|
|
2637
|
+
}
|
|
2638
|
+
function isDeliberateLoopbackHttpAuthority(authority) {
|
|
2639
|
+
return /^(?:localhost|127\.0\.0\.1|\[::1\])(?::[0-9]+)?$/i.test(authority);
|
|
2640
|
+
}
|
|
2641
|
+
function toV1BaseUrl(apiUrl) {
|
|
2642
|
+
if (ASCII_CONTROL_PATTERN.test(apiUrl)) {
|
|
2643
|
+
throw new Error("API URL must not contain ASCII control characters.");
|
|
2644
|
+
}
|
|
2645
|
+
const input = apiUrl.trim();
|
|
2646
|
+
const authority = rawAuthority(input);
|
|
2647
|
+
if (authority.includes("@") || authority.includes("\\") || authority.includes("%") || /[^\x00-\x7f]/.test(authority)) {
|
|
2648
|
+
throw new Error("API URL authority must be canonical ASCII without credentials.");
|
|
2649
|
+
}
|
|
2650
|
+
const canonicalHostname = canonicalAuthorityHostname(authority);
|
|
2651
|
+
const url = new URL(input);
|
|
2652
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
2653
|
+
throw new Error("API URL must use http or https.");
|
|
2654
|
+
}
|
|
2655
|
+
if (url.username || url.password) {
|
|
2656
|
+
throw new Error("API URL must not include credentials.");
|
|
2657
|
+
}
|
|
2658
|
+
if (!url.hostname || url.hostname.endsWith(".")) {
|
|
2659
|
+
throw new Error("API URL must include a canonical hostname.");
|
|
2660
|
+
}
|
|
2661
|
+
if (url.hostname.toLowerCase() !== canonicalHostname) {
|
|
2662
|
+
throw new Error("API URL authority must not rely on parser hostname normalization.");
|
|
2663
|
+
}
|
|
2664
|
+
if (url.hostname.split(".").some((label) => label.toLowerCase().startsWith("xn--"))) {
|
|
2665
|
+
throw new Error("API URL must not use IDN or punycode hostnames.");
|
|
2666
|
+
}
|
|
2667
|
+
if (url.protocol === "http:" && !isDeliberateLoopbackHttpAuthority(authority)) {
|
|
2668
|
+
throw new Error("API URL may use http only for an exact loopback authority.");
|
|
2669
|
+
}
|
|
2670
|
+
if (url.search || url.hash) {
|
|
2671
|
+
throw new Error("API URL must not include a query string or fragment.");
|
|
2672
|
+
}
|
|
2673
|
+
let path = url.pathname.replace(/\/+$/, "");
|
|
2674
|
+
if (path.endsWith("/v1"))
|
|
2675
|
+
path = path.slice(0, -"/v1".length);
|
|
2676
|
+
url.pathname = `${path}/v1`;
|
|
2677
|
+
return url.toString().replace(/\/+$/, "");
|
|
2678
|
+
}
|
|
2679
|
+
class ClientTransportConfigurationError extends Error {
|
|
2680
|
+
appName;
|
|
2681
|
+
sources;
|
|
2682
|
+
constructor(appName, message, sources = []) {
|
|
2683
|
+
super(message);
|
|
2684
|
+
this.name = "ClientTransportConfigurationError";
|
|
2685
|
+
this.appName = appName;
|
|
2686
|
+
this.sources = Object.freeze([...sources]);
|
|
2687
|
+
}
|
|
2688
|
+
}
|
|
2689
|
+
function resolveClientTransportSnapshot(name, env = process.env, options = {}) {
|
|
2690
|
+
env = snapshotClientEnvironment(name, env);
|
|
2691
|
+
const keys = clientTransportEnvKeys(name);
|
|
2692
|
+
const definedUrlEntries = keys.apiUrlKeys.filter((key) => Object.prototype.hasOwnProperty.call(env, key) && env[key] !== undefined).map((key) => ({ key, raw: String(env[key]) }));
|
|
2693
|
+
const blankUrl = definedUrlEntries.find((entry) => entry.raw.trim().length === 0);
|
|
2694
|
+
if (blankUrl) {
|
|
2695
|
+
throw new ClientTransportConfigurationError(name, `${blankUrl.key} is set but blank; public clients require an explicit HTTPS API URL and never select local storage.`, [blankUrl.key]);
|
|
2696
|
+
}
|
|
2697
|
+
const controlledUrl = definedUrlEntries.find((entry) => ASCII_CONTROL_PATTERN.test(entry.raw));
|
|
2698
|
+
if (controlledUrl) {
|
|
2699
|
+
throw new ClientTransportConfigurationError(name, `${controlledUrl.key} contains ASCII control characters.`, [controlledUrl.key]);
|
|
2700
|
+
}
|
|
2701
|
+
const usableUrlEntries = definedUrlEntries.map((entry) => ({ key: entry.key, value: entry.raw.trim() }));
|
|
2702
|
+
if (usableUrlEntries.length > 1 && new Set(usableUrlEntries.map((entry) => entry.value)).size > 1) {
|
|
2703
|
+
throw new ClientTransportConfigurationError(name, `${usableUrlEntries.map((entry) => entry.key).join(" and ")} disagree; client authority aliases must be identical or only one may be set.`, usableUrlEntries.map((entry) => entry.key));
|
|
2704
|
+
}
|
|
2705
|
+
const envUrlHit = usableUrlEntries[0] ?? null;
|
|
2706
|
+
const keychainUrlHit = keychainConfigValue(name, env, options.credentials?.keychain);
|
|
2707
|
+
const diskConfigUrlHit = appConfigDiskValue(name, env, keys.apiUrlKeys);
|
|
2708
|
+
if (diskConfigUrlHit?.unusable) {
|
|
2709
|
+
throw new ClientTransportConfigurationError(name, `${diskConfigUrlHit.key} in ${diskConfigUrlHit.path} is declared but blank or malformed; public clients require a valid HTTPS service authority.`, [diskConfigUrlHit.path]);
|
|
2710
|
+
}
|
|
2711
|
+
const urlCandidates = [
|
|
2712
|
+
...envUrlHit ? [envUrlHit] : [],
|
|
2713
|
+
...keychainUrlHit ? [{ key: keychainUrlHit.source, value: keychainUrlHit.value }] : [],
|
|
2714
|
+
...diskConfigUrlHit ? [{ key: diskConfigUrlHit.path, value: diskConfigUrlHit.value.trim() }] : []
|
|
2715
|
+
];
|
|
2716
|
+
const configuredUrl = urlCandidates[0] ?? null;
|
|
2717
|
+
const divergentUrls = urlCandidates.filter((candidate) => candidate.value !== configuredUrl?.value);
|
|
2718
|
+
if (configuredUrl && divergentUrls.length > 0) {
|
|
2719
|
+
throw new ClientTransportConfigurationError(name, `${configuredUrl.key} and ${divergentUrls.map((candidate) => candidate.key).join(" and ")} select different service authorities; refusing to send a credential written for one authority to the other.`, urlCandidates.map((candidate) => candidate.key));
|
|
2720
|
+
}
|
|
2721
|
+
const warnings = [];
|
|
2722
|
+
if (configuredUrl && !envUrlHit) {
|
|
2723
|
+
warnings.push(`No ${keys.apiUrlKeys[0]} in the environment; the server URL in ${configuredUrl.key} was used, so this client connects to the server. ` + `Keep that entry aligned with the intended service authority.`);
|
|
2724
|
+
}
|
|
2725
|
+
const credential = resolveCredential(name, env, options.credentials);
|
|
2726
|
+
if (!credential) {
|
|
2727
|
+
const diskHint = credentialDiskSourcesForMessage(name, env);
|
|
2728
|
+
const lead = configuredUrl ? `${configuredUrl.key} selects the HTTP server for '${name}', but no API key could be resolved` : `${keys.apiUrlKeys[0]} is not set and no API key could be resolved for '${name}'; a credential is required before the default fleet gateway authority applies`;
|
|
2729
|
+
warnings.push(`${lead}; refusing to create an unauthenticated client \u2014 public clients never fall back to SQLite or another local store. ` + `Looked in the Keychain (macOS only), then for a credential file at ${diskHint}, then for ${keys.apiKeyKeys[0]} in the environment.`);
|
|
2730
|
+
throw new ClientTransportConfigurationError(name, warnings.join(" "), [configuredUrl?.key ?? keys.apiUrlKeys[0]]);
|
|
2731
|
+
}
|
|
2732
|
+
if (credential.warning)
|
|
2733
|
+
warnings.push(credential.warning);
|
|
2734
|
+
let urlHit;
|
|
2735
|
+
if (configuredUrl) {
|
|
2736
|
+
urlHit = configuredUrl;
|
|
2737
|
+
} else {
|
|
2738
|
+
try {
|
|
2739
|
+
urlHit = { key: DEFAULT_AUTHORITY_SOURCE, value: defaultFleetGatewayBaseUrl(name) };
|
|
2740
|
+
} catch (error) {
|
|
2741
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2742
|
+
throw new ClientTransportConfigurationError(name, `No ${keys.apiUrlKeys[0]} is configured and the default fleet gateway authority cannot be composed for '${name}': ${message}`, [keys.apiUrlKeys[0]]);
|
|
2743
|
+
}
|
|
2744
|
+
}
|
|
2745
|
+
const apiUrlSource = urlHit.key;
|
|
2746
|
+
let baseUrl;
|
|
2747
|
+
try {
|
|
2748
|
+
baseUrl = toV1BaseUrl(urlHit.value);
|
|
2749
|
+
} catch (error) {
|
|
2750
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2751
|
+
throw new ClientTransportConfigurationError(name, `Invalid API URL from ${apiUrlSource}: ${message}`, [apiUrlSource]);
|
|
2752
|
+
}
|
|
2753
|
+
return {
|
|
2754
|
+
resolution: {
|
|
2755
|
+
transport: "http",
|
|
2756
|
+
transportSource: urlHit.key,
|
|
2757
|
+
baseUrl,
|
|
2758
|
+
apiUrlSource,
|
|
2759
|
+
apiKeyPresent: true,
|
|
2760
|
+
apiKeySource: credential.source,
|
|
2761
|
+
apiKeyTier: credential.tier,
|
|
2762
|
+
misconfigured: false,
|
|
2763
|
+
warning: warnings.length > 0 ? warnings.join(" ") : null
|
|
2764
|
+
},
|
|
2765
|
+
credential
|
|
2766
|
+
};
|
|
2767
|
+
}
|
|
2768
|
+
function resolveClientTransport(name, env = process.env, options = {}) {
|
|
2769
|
+
return resolveClientTransportSnapshot(name, env, options).resolution;
|
|
2770
|
+
}
|
|
2771
|
+
function credentialDiskSourcesForMessage(name, env) {
|
|
2772
|
+
const paths = credentialDiskSources(name, env);
|
|
2773
|
+
return paths.length > 0 ? paths.join(" or ") : "<no HOME or HASNA_HOME set in this environment, so no credential file was consulted>";
|
|
2774
|
+
}
|
|
2775
|
+
var IDEMPOTENT_METHODS = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]);
|
|
2776
|
+
var AUTHORITY_OVERRIDE_HEADERS = new Set([
|
|
2777
|
+
"host",
|
|
2778
|
+
":authority",
|
|
2779
|
+
"forwarded",
|
|
2780
|
+
"x-forwarded-host",
|
|
2781
|
+
"x-original-host"
|
|
2782
|
+
]);
|
|
2783
|
+
|
|
2784
|
+
// ../contracts/dist/client/storage.js
|
|
2785
|
+
import { isIP as isIP2 } from "net";
|
|
2786
|
+
import { spawnSync as spawnSync3 } from "child_process";
|
|
2787
|
+
import { closeSync as closeSync2, fstatSync as fstatSync2, openSync as openSync2, readFileSync as readFileSync3 } from "fs";
|
|
2788
|
+
import { O_NOFOLLOW as O_NOFOLLOW2, O_NONBLOCK as O_NONBLOCK2, O_RDONLY as O_RDONLY2 } from "constants";
|
|
2789
|
+
import { createRequire as createRequire2 } from "module";
|
|
2790
|
+
import { hostname as osHostname2 } from "os";
|
|
2791
|
+
import { isAbsolute as isAbsolute3, join as join5 } from "path";
|
|
2792
|
+
function envToken2(name) {
|
|
2793
|
+
return name.toUpperCase().replace(/-/g, "_");
|
|
2794
|
+
}
|
|
2795
|
+
function clientTransportEnvKeys2(name) {
|
|
2796
|
+
const envSegment = envToken2(name);
|
|
2797
|
+
return {
|
|
2798
|
+
apiUrlKeys: [`HASNA_${envSegment}_API_URL`, `${envSegment}_API_URL`],
|
|
2799
|
+
apiKeyKeys: [`HASNA_${envSegment}_API_KEY`, `${envSegment}_API_KEY`]
|
|
2800
|
+
};
|
|
2801
|
+
}
|
|
2802
|
+
function credentialOverrideEnvKey2(name) {
|
|
2803
|
+
return `HASNA_${envToken2(name)}_API_KEY_OVERRIDE`;
|
|
2804
|
+
}
|
|
2805
|
+
var CREDENTIAL_PROFILE_ENV_KEY2 = "HASNA_PROFILE";
|
|
2806
|
+
function credentialPointerEnvKey2(name) {
|
|
2807
|
+
return `HASNA_${envToken2(name)}_API_KEY_REF`;
|
|
2808
|
+
}
|
|
2809
|
+
|
|
2810
|
+
class CredentialResolutionError2 extends Error {
|
|
2811
|
+
appName;
|
|
2812
|
+
attempted;
|
|
2813
|
+
constructor(appName, message, attempted) {
|
|
2814
|
+
super(message);
|
|
2815
|
+
this.name = "CredentialResolutionError";
|
|
2816
|
+
this.appName = appName;
|
|
2817
|
+
this.attempted = attempted;
|
|
2818
|
+
}
|
|
2819
|
+
}
|
|
2820
|
+
|
|
2821
|
+
class CredentialFileUnsafeError2 extends Error {
|
|
2822
|
+
path;
|
|
2823
|
+
constructor(path, reason) {
|
|
2824
|
+
super(`Refusing unsafe credential/config file ${path}: ${reason}.`);
|
|
2825
|
+
this.name = "CredentialFileUnsafeError";
|
|
2826
|
+
this.path = path;
|
|
2827
|
+
}
|
|
2828
|
+
}
|
|
2829
|
+
var HASNA_HOME_ENV_KEY2 = "HASNA_HOME";
|
|
2830
|
+
var HASNA_CONFIG_HOME_ENV_KEY2 = "HASNA_CONFIG_HOME";
|
|
2831
|
+
var KEYCHAIN_STATION_ENV_KEY2 = "HASNA_STATION";
|
|
2832
|
+
var HASNA_HOME_DIR2 = ".hasna";
|
|
2833
|
+
var CONFIG_SUBDIR2 = "config";
|
|
2834
|
+
var CREDENTIALS_FILE2 = "credentials";
|
|
2835
|
+
var KEYCHAIN_SECURITY_BIN2 = "/usr/bin/security";
|
|
2836
|
+
var KEYCHAIN_SERVICE_PREFIX2 = "hasna.credentials";
|
|
2837
|
+
var KEYCHAIN_ITEM_NOT_FOUND_STATUS2 = 44;
|
|
2838
|
+
var KEYCHAIN_SPAWN_TIMEOUT_MS2 = 1e4;
|
|
2839
|
+
var MAX_CREDENTIAL_FILE_BYTES2 = 64 * 1024;
|
|
2840
|
+
var SAFE_APP_SLUG2 = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
|
|
2841
|
+
var SAFE_PROFILE2 = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/;
|
|
2842
|
+
var ILLEGAL_IN_HEADER_VALUE2 = /[^\t\x20-\x7e]/;
|
|
2843
|
+
var VAULT_POINTER_SHAPE2 = /^[a-z0-9][a-z0-9-]*(?:\/[a-z0-9][a-z0-9-_.]*){2,}$/;
|
|
2844
|
+
function homeDir2(env) {
|
|
2845
|
+
const home = env.HOME?.trim();
|
|
2846
|
+
return home ? home : null;
|
|
2847
|
+
}
|
|
2848
|
+
function absoluteOverride2(env, key) {
|
|
2849
|
+
const value = env[key]?.trim();
|
|
2850
|
+
return value && isAbsolute3(value) ? value : null;
|
|
2851
|
+
}
|
|
2852
|
+
function hasnaHomeDir2(env) {
|
|
2853
|
+
const override = absoluteOverride2(env, HASNA_HOME_ENV_KEY2);
|
|
2854
|
+
if (override)
|
|
2855
|
+
return override;
|
|
2856
|
+
const home = homeDir2(env);
|
|
2857
|
+
return home ? join5(home, HASNA_HOME_DIR2) : null;
|
|
2858
|
+
}
|
|
2859
|
+
function appConfigDir2(name, env) {
|
|
2860
|
+
const configRoot = absoluteOverride2(env, HASNA_CONFIG_HOME_ENV_KEY2);
|
|
2861
|
+
if (configRoot)
|
|
2862
|
+
return join5(configRoot, name);
|
|
2863
|
+
const root = hasnaHomeDir2(env);
|
|
2864
|
+
return root ? join5(root, name, CONFIG_SUBDIR2) : null;
|
|
2865
|
+
}
|
|
2866
|
+
function credentialDiskSourceList2(name, env, profile = null) {
|
|
2867
|
+
if (!SAFE_APP_SLUG2.test(name))
|
|
2868
|
+
return [];
|
|
2869
|
+
const directory = appConfigDir2(name, env);
|
|
2870
|
+
if (!directory)
|
|
2871
|
+
return [];
|
|
2872
|
+
const file = profile ? `${CREDENTIALS_FILE2}-${profile}` : CREDENTIALS_FILE2;
|
|
2873
|
+
return [{ path: join5(directory, file), tier: "disk" }];
|
|
2874
|
+
}
|
|
2875
|
+
function credentialDiskSources2(name, env) {
|
|
2876
|
+
return credentialDiskSourceList2(name, env, null).map((s) => s.path);
|
|
2877
|
+
}
|
|
2878
|
+
function profileDiskSources2(name, env, profile) {
|
|
2879
|
+
return credentialDiskSourceList2(name, env, profile).map((s) => s.path);
|
|
2880
|
+
}
|
|
2881
|
+
function parseEnvFile2(text) {
|
|
2882
|
+
const values = new Map;
|
|
2883
|
+
const unusable = new Set;
|
|
2884
|
+
for (const rawLine of text.split(/\r?\n/)) {
|
|
2885
|
+
const line = rawLine.trim();
|
|
2886
|
+
if (line.length === 0 || line.startsWith("#"))
|
|
2887
|
+
continue;
|
|
2888
|
+
const withoutExport = line.startsWith("export ") ? line.slice("export ".length).trim() : line;
|
|
2889
|
+
const equals = withoutExport.indexOf("=");
|
|
2890
|
+
if (equals <= 0)
|
|
2891
|
+
continue;
|
|
2892
|
+
const key = withoutExport.slice(0, equals).trim();
|
|
2893
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
|
|
2894
|
+
continue;
|
|
2895
|
+
let value = withoutExport.slice(equals + 1).trim();
|
|
2896
|
+
const quote = value[0];
|
|
2897
|
+
if (quote === '"' || quote === "'") {
|
|
2898
|
+
if (value.length < 2 || !value.endsWith(quote)) {
|
|
2899
|
+
unusable.add(key);
|
|
2900
|
+
continue;
|
|
2901
|
+
}
|
|
2902
|
+
value = value.slice(1, -1);
|
|
2903
|
+
}
|
|
2904
|
+
if (value.trim().length === 0) {
|
|
2905
|
+
unusable.add(key);
|
|
2906
|
+
continue;
|
|
2907
|
+
}
|
|
2908
|
+
if (values.has(key) && values.get(key) !== value)
|
|
2909
|
+
unusable.add(key);
|
|
2910
|
+
values.set(key, value);
|
|
2911
|
+
}
|
|
2912
|
+
return { values, unusable };
|
|
2913
|
+
}
|
|
2914
|
+
function configFileModeAllowed2(mode) {
|
|
2915
|
+
const permissions = mode & 4095;
|
|
2916
|
+
return permissions === 256 || permissions === 384;
|
|
2917
|
+
}
|
|
2918
|
+
function configFileReadsCoherent2(before, after) {
|
|
2919
|
+
return before.dev === after.dev && before.ino === after.ino && before.size === after.size && before.mtimeMs === after.mtimeMs && before.ctimeMs === after.ctimeMs;
|
|
2920
|
+
}
|
|
2921
|
+
function readAppConfigFile2(path) {
|
|
2922
|
+
const unsafe = (reason) => {
|
|
2923
|
+
throw new CredentialFileUnsafeError2(path, reason);
|
|
2924
|
+
};
|
|
2925
|
+
let fd = -1;
|
|
2926
|
+
try {
|
|
2927
|
+
fd = openSync2(path, O_RDONLY2 | O_NOFOLLOW2 | O_NONBLOCK2);
|
|
2928
|
+
} catch (error) {
|
|
2929
|
+
const code = error.code;
|
|
2930
|
+
if (code === "ENOENT" || code === "ENOTDIR")
|
|
2931
|
+
return null;
|
|
2932
|
+
if (code === "ELOOP")
|
|
2933
|
+
unsafe("the path is a symlink");
|
|
2934
|
+
unsafe(`the path could not be opened (${code ?? "unknown error"})`);
|
|
2935
|
+
}
|
|
2936
|
+
try {
|
|
2937
|
+
const before = fstatSync2(fd);
|
|
2938
|
+
if (!before.isFile())
|
|
2939
|
+
unsafe("the path is not a regular file");
|
|
2940
|
+
if (!configFileModeAllowed2(before.mode)) {
|
|
2941
|
+
unsafe(`permission mode ${(before.mode & 4095).toString(8).padStart(4, "0")} is not owner-only 0400 or 0600`);
|
|
2942
|
+
}
|
|
2943
|
+
const uid = process.getuid?.() ?? process.geteuid?.();
|
|
2944
|
+
if (uid !== undefined && before.uid !== uid)
|
|
2945
|
+
unsafe("the file is not owned by the current user");
|
|
2946
|
+
if (before.size > MAX_CREDENTIAL_FILE_BYTES2)
|
|
2947
|
+
unsafe("the file exceeds the size limit");
|
|
2948
|
+
const bytes = readFileSync3(fd);
|
|
2949
|
+
const after = fstatSync2(fd);
|
|
2950
|
+
if (!configFileReadsCoherent2(before, after)) {
|
|
2951
|
+
unsafe("the file changed while being read");
|
|
2952
|
+
}
|
|
2953
|
+
return parseEnvFile2(bytes.toString("utf8"));
|
|
2954
|
+
} finally {
|
|
2955
|
+
if (fd !== -1)
|
|
2956
|
+
closeSync2(fd);
|
|
2957
|
+
}
|
|
2958
|
+
}
|
|
2959
|
+
function readCredentialFile2(path, apiKeyKeys) {
|
|
2960
|
+
const parsed = readAppConfigFile2(path);
|
|
2961
|
+
if (!parsed)
|
|
2962
|
+
return null;
|
|
2963
|
+
for (const key of apiKeyKeys) {
|
|
2964
|
+
if (parsed.unusable.has(key)) {
|
|
2965
|
+
throw new CredentialFileUnsafeError2(path, `${key} is declared but blank or malformed`);
|
|
2966
|
+
}
|
|
2967
|
+
}
|
|
2968
|
+
const values = apiKeyKeys.map((key) => parsed.values.get(key)?.trim()).filter((value) => Boolean(value));
|
|
2969
|
+
if (new Set(values).size > 1) {
|
|
2970
|
+
throw new CredentialFileUnsafeError2(path, "credential aliases disagree");
|
|
2971
|
+
}
|
|
2972
|
+
return values[0] ?? null;
|
|
2973
|
+
}
|
|
2974
|
+
var CREDENTIAL_SHAPED_KEY2 = /(?:^|_)(?:API_KEY|KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|AUTH)(?:_|$)/;
|
|
2975
|
+
function appConfigDiskValue2(name, env, keys) {
|
|
2976
|
+
const wanted = keys.filter((key) => !CREDENTIAL_SHAPED_KEY2.test(key));
|
|
2977
|
+
if (wanted.length === 0)
|
|
2978
|
+
return null;
|
|
2979
|
+
for (const path of credentialDiskSources2(name, env)) {
|
|
2980
|
+
const parsed = readAppConfigFile2(path);
|
|
2981
|
+
if (!parsed)
|
|
2982
|
+
continue;
|
|
2983
|
+
if (wanted.some((key) => parsed.unusable.has(key))) {
|
|
2984
|
+
return { key: wanted.find((key) => parsed.unusable.has(key)), value: "", path, unusable: true };
|
|
2985
|
+
}
|
|
2986
|
+
const values = wanted.map((key) => parsed.values.get(key)?.trim()).filter((value) => Boolean(value));
|
|
2987
|
+
if (new Set(values).size > 1)
|
|
2988
|
+
throw new CredentialFileUnsafeError2(path, "configuration aliases disagree");
|
|
2989
|
+
for (const key of wanted) {
|
|
2990
|
+
if (parsed.unusable.has(key))
|
|
2991
|
+
return { key, value: "", path, unusable: true };
|
|
2992
|
+
const value = parsed.values.get(key)?.trim();
|
|
2993
|
+
if (value)
|
|
2994
|
+
return { key, value, path };
|
|
2995
|
+
}
|
|
2996
|
+
}
|
|
2997
|
+
return null;
|
|
2998
|
+
}
|
|
2999
|
+
function assertUsableCredential2(appName, source, value) {
|
|
3000
|
+
if (VAULT_POINTER_SHAPE2.test(value)) {
|
|
3001
|
+
throw new CredentialResolutionError2(appName, `The credential from ${source} looks like a secrets-vault pointer (a path-shaped reference like ` + `'namespace/app/live/api_key'). A vault path is NEVER accepted as a literal API key. ` + `Use ${credentialPointerEnvKey2(appName)} to resolve the key through the vault, or provide the actual key value.`, [source]);
|
|
3002
|
+
}
|
|
3003
|
+
if (!ILLEGAL_IN_HEADER_VALUE2.test(value))
|
|
3004
|
+
return;
|
|
3005
|
+
throw new CredentialResolutionError2(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]);
|
|
3006
|
+
}
|
|
3007
|
+
var INSPECT_CUSTOM2 = Symbol.for("nodejs.util.inspect.custom");
|
|
3008
|
+
var CREDENTIAL_SEAL2 = Symbol.for("hasna:contracts:sealedCredential");
|
|
3009
|
+
var CALLER_SUPPLIED_CREDENTIAL_PROVIDER_SOURCE = "caller-supplied CredentialProvider";
|
|
3010
|
+
function sealCredential2(fields) {
|
|
3011
|
+
const { apiKey } = fields;
|
|
3012
|
+
const visible = {
|
|
3013
|
+
tier: fields.tier,
|
|
3014
|
+
source: fields.source,
|
|
3015
|
+
deliberate: fields.deliberate,
|
|
3016
|
+
diskCandidates: Object.freeze([...fields.diskCandidates]),
|
|
3017
|
+
warning: fields.warning
|
|
3018
|
+
};
|
|
3019
|
+
const sealed = { ...visible };
|
|
3020
|
+
Object.defineProperty(sealed, "apiKey", {
|
|
3021
|
+
value: apiKey,
|
|
3022
|
+
enumerable: false,
|
|
3023
|
+
writable: false,
|
|
3024
|
+
configurable: false
|
|
3025
|
+
});
|
|
3026
|
+
if (fields.pointerVaultKey !== undefined) {
|
|
3027
|
+
Object.defineProperty(sealed, "pointerVaultKey", {
|
|
3028
|
+
value: fields.pointerVaultKey,
|
|
3029
|
+
enumerable: false,
|
|
3030
|
+
writable: false,
|
|
3031
|
+
configurable: false
|
|
3032
|
+
});
|
|
3033
|
+
}
|
|
3034
|
+
Object.defineProperty(sealed, INSPECT_CUSTOM2, {
|
|
3035
|
+
value: () => ({ ...visible, apiKey: "[redacted]" }),
|
|
3036
|
+
enumerable: false,
|
|
3037
|
+
writable: false,
|
|
3038
|
+
configurable: false
|
|
3039
|
+
});
|
|
3040
|
+
Object.defineProperty(sealed, CREDENTIAL_SEAL2, {
|
|
3041
|
+
value: true,
|
|
3042
|
+
enumerable: false,
|
|
3043
|
+
writable: false,
|
|
3044
|
+
configurable: false
|
|
3045
|
+
});
|
|
3046
|
+
return Object.freeze(sealed);
|
|
3047
|
+
}
|
|
3048
|
+
function isSealedCredential(credential) {
|
|
3049
|
+
return credential[CREDENTIAL_SEAL2] === true;
|
|
3050
|
+
}
|
|
3051
|
+
function explicitCredential(appName, apiKey) {
|
|
3052
|
+
const source = "explicit apiKey option";
|
|
3053
|
+
assertUsableCredential2(appName, source, apiKey);
|
|
3054
|
+
return sealCredential2({
|
|
3055
|
+
apiKey,
|
|
3056
|
+
tier: "argument",
|
|
3057
|
+
source,
|
|
3058
|
+
deliberate: true,
|
|
3059
|
+
diskCandidates: [],
|
|
3060
|
+
warning: null
|
|
3061
|
+
});
|
|
3062
|
+
}
|
|
3063
|
+
function validateAndSealResolvedCredential(appName, credential) {
|
|
3064
|
+
const apiKey = credential.apiKey;
|
|
3065
|
+
assertUsableCredential2(appName, CALLER_SUPPLIED_CREDENTIAL_PROVIDER_SOURCE, apiKey);
|
|
3066
|
+
if (!isSealedCredential(credential)) {
|
|
3067
|
+
return sealCredential2({
|
|
3068
|
+
apiKey,
|
|
3069
|
+
tier: "argument",
|
|
3070
|
+
source: CALLER_SUPPLIED_CREDENTIAL_PROVIDER_SOURCE,
|
|
3071
|
+
deliberate: true,
|
|
3072
|
+
diskCandidates: [],
|
|
3073
|
+
warning: null
|
|
3074
|
+
});
|
|
3075
|
+
}
|
|
3076
|
+
return sealCredential2({
|
|
3077
|
+
apiKey,
|
|
3078
|
+
tier: credential.tier,
|
|
3079
|
+
source: credential.source,
|
|
3080
|
+
deliberate: credential.deliberate,
|
|
3081
|
+
diskCandidates: credential.diskCandidates,
|
|
3082
|
+
warning: credential.warning,
|
|
3083
|
+
...credential.pointerVaultKey !== undefined ? { pointerVaultKey: credential.pointerVaultKey } : {}
|
|
3084
|
+
});
|
|
3085
|
+
}
|
|
3086
|
+
function firstEnvValue2(env, keys) {
|
|
3087
|
+
for (const key of keys) {
|
|
3088
|
+
if (!Object.prototype.hasOwnProperty.call(env, key))
|
|
3089
|
+
continue;
|
|
3090
|
+
const value = env[key]?.trim();
|
|
3091
|
+
if (value)
|
|
3092
|
+
return { key, value };
|
|
3093
|
+
}
|
|
3094
|
+
return null;
|
|
3095
|
+
}
|
|
3096
|
+
var AMBIENT_ENVIRONMENT2 = Symbol.for("hasna:contracts:ambientClientEnvironment");
|
|
3097
|
+
function isAmbientEnvironment2(env) {
|
|
3098
|
+
return env === process.env || env[AMBIENT_ENVIRONMENT2] === true;
|
|
3099
|
+
}
|
|
3100
|
+
function defaultKeychainRunner2(argv) {
|
|
3101
|
+
const result = spawnSync3(KEYCHAIN_SECURITY_BIN2, [...argv], {
|
|
3102
|
+
encoding: "utf8",
|
|
3103
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
3104
|
+
timeout: KEYCHAIN_SPAWN_TIMEOUT_MS2
|
|
3105
|
+
});
|
|
3106
|
+
return {
|
|
3107
|
+
status: result.status,
|
|
3108
|
+
stdout: result.stdout ?? "",
|
|
3109
|
+
stderr: result.error ? result.error.message : result.stderr ?? ""
|
|
3110
|
+
};
|
|
3111
|
+
}
|
|
3112
|
+
function keychainTierEnabled2(env, options) {
|
|
3113
|
+
if ((options.platform ?? process.platform) !== "darwin")
|
|
3114
|
+
return false;
|
|
3115
|
+
if (options.enabled !== undefined)
|
|
3116
|
+
return options.enabled;
|
|
3117
|
+
return options.run !== undefined || isAmbientEnvironment2(env);
|
|
3118
|
+
}
|
|
3119
|
+
function keychainAccount2(env, options) {
|
|
3120
|
+
const station = env[KEYCHAIN_STATION_ENV_KEY2]?.trim();
|
|
3121
|
+
if (station)
|
|
3122
|
+
return station;
|
|
3123
|
+
const host = (options.hostname ?? osHostname2)().split(".")[0]?.trim() ?? "";
|
|
3124
|
+
if (host)
|
|
3125
|
+
return host;
|
|
3126
|
+
const user = env.USER?.trim();
|
|
3127
|
+
return user || null;
|
|
3128
|
+
}
|
|
3129
|
+
function keychainFailureHint2(text) {
|
|
3130
|
+
const line = text.split(/\r?\n/).find((entry) => entry.trim().length > 0)?.trim() ?? "";
|
|
3131
|
+
const clean = line.replace(/[\u0000-\u001f\u007f]/g, "").slice(0, 200);
|
|
3132
|
+
return clean ? `: ${clean}` : "";
|
|
3133
|
+
}
|
|
3134
|
+
function readKeychainItem2(name, env, kind, options) {
|
|
3135
|
+
if (!SAFE_APP_SLUG2.test(name) || !keychainTierEnabled2(env, options))
|
|
3136
|
+
return null;
|
|
3137
|
+
const account = keychainAccount2(env, options);
|
|
3138
|
+
if (!account)
|
|
3139
|
+
return null;
|
|
3140
|
+
const service = `${KEYCHAIN_SERVICE_PREFIX2}.${name}.${kind}`;
|
|
3141
|
+
const source = `keychain:${service}@${account}`;
|
|
3142
|
+
const run = options.run ?? defaultKeychainRunner2;
|
|
3143
|
+
let result;
|
|
3144
|
+
try {
|
|
3145
|
+
result = run(["find-generic-password", "-a", account, "-s", service, "-w"]);
|
|
3146
|
+
} catch (error) {
|
|
3147
|
+
const reason = keychainFailureHint2(error instanceof Error ? error.message : String(error));
|
|
3148
|
+
throw new CredentialResolutionError2(name, `The Keychain lookup for ${source} could not run${reason}. A Keychain failure is never resolved ` + `around: fix the keychain, or delete the item to fall through to the credential on disk.`, [source]);
|
|
3149
|
+
}
|
|
3150
|
+
if (result.status === KEYCHAIN_ITEM_NOT_FOUND_STATUS2)
|
|
3151
|
+
return null;
|
|
3152
|
+
if (result.status !== 0) {
|
|
3153
|
+
throw new CredentialResolutionError2(name, `The Keychain lookup for ${source} failed (security exited ` + `${result.status ?? "without a status"}${keychainFailureHint2(result.stderr)}). A Keychain item that ` + `exists but cannot be read is never resolved around: unlock the keychain, run from a session that ` + `may use it, or delete the item to fall through to the credential on disk.`, [source]);
|
|
3154
|
+
}
|
|
3155
|
+
const value = result.stdout.trim();
|
|
3156
|
+
if (!value) {
|
|
3157
|
+
throw new CredentialResolutionError2(name, `${source} exists but holds an empty value; a declared item never falls through to another ` + `identity. Store a value in it or delete the item.`, [source]);
|
|
3158
|
+
}
|
|
3159
|
+
return { value, source };
|
|
3160
|
+
}
|
|
3161
|
+
function keychainConfigValue2(name, env, options = {}) {
|
|
3162
|
+
return readKeychainItem2(name, env, "api-url", options);
|
|
3163
|
+
}
|
|
3164
|
+
function snapshotClientEnvironment2(name, env) {
|
|
3165
|
+
const keys = clientTransportEnvKeys2(name);
|
|
3166
|
+
const ambient = isAmbientEnvironment2(env);
|
|
3167
|
+
const snapshot = Object.create(null);
|
|
3168
|
+
for (const key of [
|
|
3169
|
+
...keys.apiUrlKeys,
|
|
3170
|
+
...keys.apiKeyKeys,
|
|
3171
|
+
credentialOverrideEnvKey2(name),
|
|
3172
|
+
credentialPointerEnvKey2(name),
|
|
3173
|
+
CREDENTIAL_PROFILE_ENV_KEY2,
|
|
3174
|
+
"HOME",
|
|
3175
|
+
HASNA_HOME_ENV_KEY2,
|
|
3176
|
+
HASNA_CONFIG_HOME_ENV_KEY2,
|
|
3177
|
+
KEYCHAIN_STATION_ENV_KEY2,
|
|
3178
|
+
"USER"
|
|
3179
|
+
]) {
|
|
3180
|
+
const descriptor = Object.getOwnPropertyDescriptor(env, key);
|
|
3181
|
+
if (!descriptor)
|
|
3182
|
+
continue;
|
|
3183
|
+
if (!("value" in descriptor)) {
|
|
3184
|
+
throw new CredentialResolutionError2(name, `${key} is accessor-backed; client configuration requires own data properties.`, [key]);
|
|
3185
|
+
}
|
|
3186
|
+
if (descriptor.value !== undefined && typeof descriptor.value !== "string") {
|
|
3187
|
+
throw new CredentialResolutionError2(name, `${key} must be a string data property.`, [key]);
|
|
3188
|
+
}
|
|
3189
|
+
snapshot[key] = descriptor.value;
|
|
3190
|
+
}
|
|
3191
|
+
if (ambient) {
|
|
3192
|
+
Object.defineProperty(snapshot, AMBIENT_ENVIRONMENT2, {
|
|
3193
|
+
value: true,
|
|
3194
|
+
enumerable: false,
|
|
3195
|
+
writable: false,
|
|
3196
|
+
configurable: false
|
|
3197
|
+
});
|
|
3198
|
+
}
|
|
3199
|
+
return Object.freeze(snapshot);
|
|
3200
|
+
}
|
|
3201
|
+
function resolveCredential2(name, env, options = {}) {
|
|
3202
|
+
env = snapshotClientEnvironment2(name, env);
|
|
3203
|
+
const { apiKeyKeys } = clientTransportEnvKeys2(name);
|
|
3204
|
+
const diskPaths = credentialDiskSources2(name, env);
|
|
3205
|
+
if (options.apiKey !== undefined) {
|
|
3206
|
+
const explicitKey = options.apiKey.trim();
|
|
3207
|
+
if (!explicitKey) {
|
|
3208
|
+
throw new CredentialResolutionError2(name, "The explicit apiKey argument is blank; an explicit credential never falls through to another identity.", ["explicit apiKey argument"]);
|
|
3209
|
+
}
|
|
3210
|
+
assertUsableCredential2(name, "the explicit apiKey argument", explicitKey);
|
|
3211
|
+
return sealCredential2({
|
|
3212
|
+
apiKey: explicitKey,
|
|
3213
|
+
tier: "argument",
|
|
3214
|
+
source: "explicit apiKey argument",
|
|
3215
|
+
deliberate: true,
|
|
3216
|
+
diskCandidates: diskPaths,
|
|
3217
|
+
warning: null
|
|
3218
|
+
});
|
|
3219
|
+
}
|
|
3220
|
+
const overrideKeyName = credentialOverrideEnvKey2(name);
|
|
3221
|
+
const overrideRaw = Object.prototype.hasOwnProperty.call(env, overrideKeyName) ? env[overrideKeyName] : undefined;
|
|
3222
|
+
if (overrideRaw !== undefined) {
|
|
3223
|
+
const override = overrideRaw.trim();
|
|
3224
|
+
if (!override) {
|
|
3225
|
+
throw new CredentialResolutionError2(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]);
|
|
3226
|
+
}
|
|
3227
|
+
assertUsableCredential2(name, overrideKeyName, override);
|
|
3228
|
+
return sealCredential2({
|
|
3229
|
+
apiKey: override,
|
|
3230
|
+
tier: "override",
|
|
3231
|
+
source: overrideKeyName,
|
|
3232
|
+
deliberate: true,
|
|
3233
|
+
diskCandidates: diskPaths,
|
|
3234
|
+
warning: null
|
|
3235
|
+
});
|
|
3236
|
+
}
|
|
3237
|
+
const pointerKeyName = credentialPointerEnvKey2(name);
|
|
3238
|
+
const pointerRaw = Object.prototype.hasOwnProperty.call(env, pointerKeyName) ? env[pointerKeyName] : undefined;
|
|
3239
|
+
if (pointerRaw !== undefined) {
|
|
3240
|
+
const pointer = pointerRaw.trim();
|
|
3241
|
+
if (!pointer) {
|
|
3242
|
+
throw new CredentialResolutionError2(name, `${pointerKeyName} is set but empty. It is a deliberate vault pointer, so it is not resolved around: ` + `either give it a vault item key or unset it to fall back to the credential on disk.`, [pointerKeyName]);
|
|
3243
|
+
}
|
|
3244
|
+
if (!VAULT_POINTER_SHAPE2.test(pointer)) {
|
|
3245
|
+
throw new CredentialResolutionError2(name, `${pointerKeyName} must name a vault ITEM KEY (a path-shaped reference like ` + `'namespace/app/live/api_key'), not a credential value. A pointer that carries a literal is refused.`, [pointerKeyName]);
|
|
3246
|
+
}
|
|
3247
|
+
return sealCredential2({
|
|
3248
|
+
apiKey: "",
|
|
3249
|
+
pointerVaultKey: pointer,
|
|
3250
|
+
tier: "pointer",
|
|
3251
|
+
source: pointerKeyName,
|
|
3252
|
+
deliberate: true,
|
|
3253
|
+
diskCandidates: diskPaths,
|
|
3254
|
+
warning: null
|
|
3255
|
+
});
|
|
3256
|
+
}
|
|
3257
|
+
if (options.profile !== undefined && !options.profile.trim()) {
|
|
3258
|
+
throw new CredentialResolutionError2(name, "The explicit profile argument is blank; an explicit identity selection never falls through.", ["explicit profile argument"]);
|
|
3259
|
+
}
|
|
3260
|
+
const profileRaw = Object.prototype.hasOwnProperty.call(env, CREDENTIAL_PROFILE_ENV_KEY2) ? env[CREDENTIAL_PROFILE_ENV_KEY2] : undefined;
|
|
3261
|
+
if (profileRaw !== undefined && !profileRaw.trim()) {
|
|
3262
|
+
throw new CredentialResolutionError2(name, `${CREDENTIAL_PROFILE_ENV_KEY2} is set but blank.`, [CREDENTIAL_PROFILE_ENV_KEY2]);
|
|
3263
|
+
}
|
|
3264
|
+
const profile = options.profile?.trim() || profileRaw?.trim();
|
|
3265
|
+
if (profile) {
|
|
3266
|
+
const profileSource = options.profile?.trim() ? "explicit profile argument" : CREDENTIAL_PROFILE_ENV_KEY2;
|
|
3267
|
+
if (!SAFE_PROFILE2.test(profile)) {
|
|
3268
|
+
throw new CredentialResolutionError2(name, `Profile name from ${profileSource} is not usable in a path. ` + `Use letters, digits, dot, dash, or underscore.`, [profileSource]);
|
|
3269
|
+
}
|
|
3270
|
+
const paths = profileDiskSources2(name, env, profile);
|
|
3271
|
+
for (const path of paths) {
|
|
3272
|
+
const value = readCredentialFile2(path, apiKeyKeys);
|
|
3273
|
+
if (value) {
|
|
3274
|
+
assertUsableCredential2(name, path, value);
|
|
3275
|
+
return sealCredential2({
|
|
3276
|
+
apiKey: value,
|
|
3277
|
+
tier: "profile",
|
|
3278
|
+
source: path,
|
|
3279
|
+
deliberate: true,
|
|
3280
|
+
diskCandidates: paths,
|
|
3281
|
+
warning: null
|
|
3282
|
+
});
|
|
3283
|
+
}
|
|
3284
|
+
}
|
|
3285
|
+
throw new CredentialResolutionError2(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_KEY2}.`, paths);
|
|
3286
|
+
}
|
|
3287
|
+
const definedEnvEntries = apiKeyKeys.filter((key) => Object.prototype.hasOwnProperty.call(env, key) && env[key] !== undefined).map((key) => ({ key, value: String(env[key]).trim() }));
|
|
3288
|
+
const blankEnv = definedEnvEntries.find((entry) => entry.value.length === 0);
|
|
3289
|
+
if (blankEnv) {
|
|
3290
|
+
throw new CredentialResolutionError2(name, `${blankEnv.key} is set but blank; a declared credential never falls through to another alias or identity.`, [blankEnv.key]);
|
|
3291
|
+
}
|
|
3292
|
+
if (definedEnvEntries.length > 1 && new Set(definedEnvEntries.map((entry) => entry.value)).size > 1) {
|
|
3293
|
+
throw new CredentialResolutionError2(name, `${definedEnvEntries.map((entry) => entry.key).join(" and ")} disagree; credential aliases must be identical or only one may be set.`, definedEnvEntries.map((entry) => entry.key));
|
|
3294
|
+
}
|
|
3295
|
+
const envHit = firstEnvValue2(env, apiKeyKeys);
|
|
3296
|
+
const keychainHit = readKeychainItem2(name, env, "api-key", options.keychain ?? {});
|
|
3297
|
+
if (keychainHit) {
|
|
3298
|
+
assertUsableCredential2(name, keychainHit.source, keychainHit.value);
|
|
3299
|
+
const warning = envHit && envHit.value !== keychainHit.value ? `Credential sources disagree for '${name}': ${keychainHit.source} and ${envHit.key} hold ` + `different keys. ${keychainHit.source} wins, because the Keychain 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;
|
|
3300
|
+
return sealCredential2({
|
|
3301
|
+
apiKey: keychainHit.value,
|
|
3302
|
+
tier: "keychain",
|
|
3303
|
+
source: keychainHit.source,
|
|
3304
|
+
deliberate: false,
|
|
3305
|
+
diskCandidates: diskPaths,
|
|
3306
|
+
warning
|
|
3307
|
+
});
|
|
3308
|
+
}
|
|
3309
|
+
const diskSourceList = credentialDiskSourceList2(name, env, null);
|
|
3310
|
+
const diskHits = diskSourceList.map((src) => ({ src, value: readCredentialFile2(src.path, apiKeyKeys) })).filter((hit) => hit.value !== null);
|
|
3311
|
+
if (diskHits.length > 0) {
|
|
3312
|
+
const winner = diskHits[0];
|
|
3313
|
+
assertUsableCredential2(name, winner.src.path, winner.value);
|
|
3314
|
+
const divergentSources = [
|
|
3315
|
+
...diskHits.slice(1).filter((hit) => hit.value !== winner.value).map((hit) => hit.src.path),
|
|
3316
|
+
...envHit && envHit.value !== winner.value ? [envHit.key] : []
|
|
3317
|
+
];
|
|
3318
|
+
const warning = divergentSources.length > 0 ? `Credential sources disagree for '${name}': ${winner.src.path} and ` + `${divergentSources.join(", ")} hold different keys. ${winner.src.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;
|
|
3319
|
+
return sealCredential2({
|
|
3320
|
+
apiKey: winner.value,
|
|
3321
|
+
tier: winner.src.tier,
|
|
3322
|
+
source: winner.src.path,
|
|
3323
|
+
deliberate: false,
|
|
3324
|
+
diskCandidates: diskPaths,
|
|
3325
|
+
warning
|
|
3326
|
+
});
|
|
3327
|
+
}
|
|
3328
|
+
if (envHit) {
|
|
3329
|
+
assertUsableCredential2(name, envHit.key, envHit.value);
|
|
3330
|
+
return sealCredential2({
|
|
3331
|
+
apiKey: envHit.value,
|
|
3332
|
+
tier: "env",
|
|
3333
|
+
source: envHit.key,
|
|
3334
|
+
deliberate: false,
|
|
3335
|
+
diskCandidates: diskPaths,
|
|
3336
|
+
warning: null
|
|
3337
|
+
});
|
|
3338
|
+
}
|
|
3339
|
+
return null;
|
|
3340
|
+
}
|
|
3341
|
+
var SECRETS_PACKAGE_SPECIFIER2 = "@hasna/" + "secrets";
|
|
3342
|
+
var requireSecretsSdk2 = createRequire2(import.meta.url);
|
|
3343
|
+
async function completePointerCredential(name, pointerResolution, env = process.env) {
|
|
3344
|
+
const vaultKey = pointerResolution.pointerVaultKey;
|
|
3345
|
+
const pointerEnvKey = pointerResolution.source;
|
|
3346
|
+
if (!vaultKey) {
|
|
3347
|
+
throw new CredentialResolutionError2(name, `Pointer resolution from ${pointerEnvKey} carries no vault item key; this is a defect in the resolver.`, [pointerEnvKey]);
|
|
3348
|
+
}
|
|
3349
|
+
let secretsSdk;
|
|
3350
|
+
try {
|
|
3351
|
+
secretsSdk = requireSecretsSdk2(SECRETS_PACKAGE_SPECIFIER2);
|
|
3352
|
+
} catch {
|
|
3353
|
+
throw new CredentialResolutionError2(name, `${pointerEnvKey} names vault item '${vaultKey}', but the secrets SDK (@hasna/secrets) is not installed ` + `in this process. A vault pointer is TERMINAL: install @hasna/secrets to resolve it, or unset ${pointerEnvKey}.`, [pointerEnvKey]);
|
|
3354
|
+
}
|
|
3355
|
+
let client;
|
|
3356
|
+
try {
|
|
3357
|
+
client = secretsSdk.createSecretsClientFromEnv(env);
|
|
3358
|
+
} catch {
|
|
3359
|
+
throw new CredentialResolutionError2(name, `${pointerEnvKey} names vault item '${vaultKey}', but the secrets client could not be configured from this ` + `environment (the secrets service URL and key env are missing or invalid). A vault pointer is TERMINAL and ` + `never falls through to a literal or disk credential.`, [pointerEnvKey]);
|
|
3360
|
+
}
|
|
3361
|
+
let secret;
|
|
3362
|
+
try {
|
|
3363
|
+
secret = await client.getSecret({ key: vaultKey });
|
|
3364
|
+
} catch {
|
|
3365
|
+
throw new CredentialResolutionError2(name, `${pointerEnvKey} names vault item '${vaultKey}', but the vault could not be reached or the item is ` + `unavailable. A vault pointer is TERMINAL and never falls through to a literal or disk credential.`, [pointerEnvKey]);
|
|
3366
|
+
}
|
|
3367
|
+
const value = secret.value;
|
|
3368
|
+
if (!value) {
|
|
3369
|
+
throw new CredentialResolutionError2(name, `${pointerEnvKey} resolved vault item '${vaultKey}', but it holds no value. A vault pointer is TERMINAL.`, [pointerEnvKey]);
|
|
3370
|
+
}
|
|
3371
|
+
assertUsableCredential2(name, `${pointerEnvKey} -> vault:${vaultKey}`, value);
|
|
3372
|
+
return sealCredential2({
|
|
3373
|
+
apiKey: value,
|
|
3374
|
+
tier: "pointer",
|
|
3375
|
+
source: `${pointerEnvKey} -> vault:${vaultKey}`,
|
|
3376
|
+
deliberate: true,
|
|
3377
|
+
diskCandidates: pointerResolution.diskCandidates,
|
|
3378
|
+
warning: null
|
|
3379
|
+
});
|
|
3380
|
+
}
|
|
3381
|
+
var DEFAULT_FLEET_GATEWAY_ORIGIN2 = "https://api.hasna.com";
|
|
3382
|
+
var DEFAULT_AUTHORITY_SOURCE2 = "default";
|
|
3383
|
+
function defaultFleetGatewayBaseUrl2(name) {
|
|
3384
|
+
return `${DEFAULT_FLEET_GATEWAY_ORIGIN2}/${validateAppSlug2(name)}`;
|
|
3385
|
+
}
|
|
3386
|
+
var ASCII_CONTROL_PATTERN2 = /[\u0000-\u001f\u007f]/;
|
|
3387
|
+
var DNS_LABEL_PATTERN2 = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
|
|
3388
|
+
function isValidDnsDomain2(value) {
|
|
3389
|
+
if (value.length === 0 || value.length > 253 || ASCII_CONTROL_PATTERN2.test(value) || /[^\x00-\x7f]/.test(value)) {
|
|
3390
|
+
return false;
|
|
2388
3391
|
}
|
|
2389
|
-
return
|
|
3392
|
+
return value.split(".").every((label) => label.length <= 63 && !label.startsWith("xn--") && DNS_LABEL_PATTERN2.test(label));
|
|
2390
3393
|
}
|
|
2391
|
-
function
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
return key;
|
|
3394
|
+
function validateAppSlug2(name) {
|
|
3395
|
+
if (name.length > 63 || !DNS_LABEL_PATTERN2.test(name)) {
|
|
3396
|
+
throw new Error("App name must be one lowercase DNS label.");
|
|
2395
3397
|
}
|
|
2396
|
-
return
|
|
3398
|
+
return name;
|
|
2397
3399
|
}
|
|
2398
|
-
function
|
|
3400
|
+
function rawAuthority2(value) {
|
|
2399
3401
|
const match = /^[a-z][a-z0-9+.-]*:\/\//i.exec(value);
|
|
2400
3402
|
if (!match)
|
|
2401
3403
|
throw new Error("API URL must be absolute.");
|
|
@@ -2406,7 +3408,7 @@ function rawAuthority(value) {
|
|
|
2406
3408
|
throw new Error("API URL must include a hostname.");
|
|
2407
3409
|
return authority;
|
|
2408
3410
|
}
|
|
2409
|
-
function
|
|
3411
|
+
function assertCanonicalPort2(port) {
|
|
2410
3412
|
if (!/^[0-9]+$/.test(port) || port.length > 1 && port.startsWith("0")) {
|
|
2411
3413
|
throw new Error("API URL authority must contain a canonical port between 1 and 65535.");
|
|
2412
3414
|
}
|
|
@@ -2415,7 +3417,7 @@ function assertCanonicalPort(port) {
|
|
|
2415
3417
|
throw new Error("API URL authority must contain a canonical port between 1 and 65535.");
|
|
2416
3418
|
}
|
|
2417
3419
|
}
|
|
2418
|
-
function
|
|
3420
|
+
function canonicalAuthorityHostname2(authority) {
|
|
2419
3421
|
let rawHostname;
|
|
2420
3422
|
if (authority.startsWith("[")) {
|
|
2421
3423
|
const closingBracket = authority.indexOf("]");
|
|
@@ -2428,9 +3430,9 @@ function canonicalAuthorityHostname(authority) {
|
|
|
2428
3430
|
if (!portSuffix.startsWith(":")) {
|
|
2429
3431
|
throw new Error("API URL authority must contain a canonical hostname and port.");
|
|
2430
3432
|
}
|
|
2431
|
-
|
|
3433
|
+
assertCanonicalPort2(portSuffix.slice(1));
|
|
2432
3434
|
}
|
|
2433
|
-
if (
|
|
3435
|
+
if (isIP2(rawHostname.slice(1, -1)) !== 6) {
|
|
2434
3436
|
throw new Error("API URL authority must contain a canonical IPv6 literal.");
|
|
2435
3437
|
}
|
|
2436
3438
|
} else {
|
|
@@ -2441,33 +3443,33 @@ function canonicalAuthorityHostname(authority) {
|
|
|
2441
3443
|
}
|
|
2442
3444
|
if (lastColon !== -1) {
|
|
2443
3445
|
const port = authority.slice(lastColon + 1);
|
|
2444
|
-
|
|
3446
|
+
assertCanonicalPort2(port);
|
|
2445
3447
|
rawHostname = authority.slice(0, lastColon);
|
|
2446
3448
|
} else {
|
|
2447
3449
|
rawHostname = authority;
|
|
2448
3450
|
}
|
|
2449
|
-
const ipVersion =
|
|
3451
|
+
const ipVersion = isIP2(rawHostname);
|
|
2450
3452
|
const numericAddressParts = rawHostname.split(".");
|
|
2451
3453
|
const looksLikeNonCanonicalIpv4 = numericAddressParts.every((part) => /^(?:0x[0-9a-f]+|[0-9]+)$/i.test(part));
|
|
2452
|
-
if (ipVersion !== 4 && looksLikeNonCanonicalIpv4 || ipVersion !== 4 && !
|
|
3454
|
+
if (ipVersion !== 4 && looksLikeNonCanonicalIpv4 || ipVersion !== 4 && !isValidDnsDomain2(rawHostname.toLowerCase())) {
|
|
2453
3455
|
throw new Error("API URL authority must contain a canonical ASCII hostname.");
|
|
2454
3456
|
}
|
|
2455
3457
|
}
|
|
2456
3458
|
return rawHostname.toLowerCase();
|
|
2457
3459
|
}
|
|
2458
|
-
function
|
|
3460
|
+
function isDeliberateLoopbackHttpAuthority2(authority) {
|
|
2459
3461
|
return /^(?:localhost|127\.0\.0\.1|\[::1\])(?::[0-9]+)?$/i.test(authority);
|
|
2460
3462
|
}
|
|
2461
|
-
function
|
|
2462
|
-
if (
|
|
3463
|
+
function toV1BaseUrl2(apiUrl) {
|
|
3464
|
+
if (ASCII_CONTROL_PATTERN2.test(apiUrl)) {
|
|
2463
3465
|
throw new Error("API URL must not contain ASCII control characters.");
|
|
2464
3466
|
}
|
|
2465
3467
|
const input = apiUrl.trim();
|
|
2466
|
-
const authority =
|
|
3468
|
+
const authority = rawAuthority2(input);
|
|
2467
3469
|
if (authority.includes("@") || authority.includes("\\") || authority.includes("%") || /[^\x00-\x7f]/.test(authority)) {
|
|
2468
3470
|
throw new Error("API URL authority must be canonical ASCII without credentials.");
|
|
2469
3471
|
}
|
|
2470
|
-
const canonicalHostname =
|
|
3472
|
+
const canonicalHostname = canonicalAuthorityHostname2(authority);
|
|
2471
3473
|
const url = new URL(input);
|
|
2472
3474
|
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
2473
3475
|
throw new Error("API URL must use http or https.");
|
|
@@ -2484,7 +3486,7 @@ function toV1BaseUrl(apiUrl) {
|
|
|
2484
3486
|
if (url.hostname.split(".").some((label) => label.toLowerCase().startsWith("xn--"))) {
|
|
2485
3487
|
throw new Error("API URL must not use IDN or punycode hostnames.");
|
|
2486
3488
|
}
|
|
2487
|
-
if (url.protocol === "http:" && !
|
|
3489
|
+
if (url.protocol === "http:" && !isDeliberateLoopbackHttpAuthority2(authority)) {
|
|
2488
3490
|
throw new Error("API URL may use http only for an exact loopback authority.");
|
|
2489
3491
|
}
|
|
2490
3492
|
if (url.search || url.hash) {
|
|
@@ -2496,106 +3498,104 @@ function toV1BaseUrl(apiUrl) {
|
|
|
2496
3498
|
url.pathname = `${path}/v1`;
|
|
2497
3499
|
return url.toString().replace(/\/+$/, "");
|
|
2498
3500
|
}
|
|
2499
|
-
|
|
2500
|
-
|
|
2501
|
-
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
|
|
2510
|
-
|
|
2511
|
-
|
|
2512
|
-
|
|
2513
|
-
|
|
2514
|
-
|
|
2515
|
-
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
2525
|
-
|
|
2526
|
-
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
|
|
2530
|
-
|
|
2531
|
-
|
|
2532
|
-
|
|
2533
|
-
|
|
2534
|
-
|
|
2535
|
-
|
|
3501
|
+
class ClientTransportConfigurationError2 extends Error {
|
|
3502
|
+
appName;
|
|
3503
|
+
sources;
|
|
3504
|
+
constructor(appName, message, sources = []) {
|
|
3505
|
+
super(message);
|
|
3506
|
+
this.name = "ClientTransportConfigurationError";
|
|
3507
|
+
this.appName = appName;
|
|
3508
|
+
this.sources = Object.freeze([...sources]);
|
|
3509
|
+
}
|
|
3510
|
+
}
|
|
3511
|
+
function resolveClientTransportSnapshot2(name, env = process.env, options = {}) {
|
|
3512
|
+
env = snapshotClientEnvironment2(name, env);
|
|
3513
|
+
const keys = clientTransportEnvKeys2(name);
|
|
3514
|
+
const definedUrlEntries = keys.apiUrlKeys.filter((key) => Object.prototype.hasOwnProperty.call(env, key) && env[key] !== undefined).map((key) => ({ key, raw: String(env[key]) }));
|
|
3515
|
+
const blankUrl = definedUrlEntries.find((entry) => entry.raw.trim().length === 0);
|
|
3516
|
+
if (blankUrl) {
|
|
3517
|
+
throw new ClientTransportConfigurationError2(name, `${blankUrl.key} is set but blank; public clients require an explicit HTTPS API URL and never select local storage.`, [blankUrl.key]);
|
|
3518
|
+
}
|
|
3519
|
+
const controlledUrl = definedUrlEntries.find((entry) => ASCII_CONTROL_PATTERN2.test(entry.raw));
|
|
3520
|
+
if (controlledUrl) {
|
|
3521
|
+
throw new ClientTransportConfigurationError2(name, `${controlledUrl.key} contains ASCII control characters.`, [controlledUrl.key]);
|
|
3522
|
+
}
|
|
3523
|
+
const usableUrlEntries = definedUrlEntries.map((entry) => ({ key: entry.key, value: entry.raw.trim() }));
|
|
3524
|
+
if (usableUrlEntries.length > 1 && new Set(usableUrlEntries.map((entry) => entry.value)).size > 1) {
|
|
3525
|
+
throw new ClientTransportConfigurationError2(name, `${usableUrlEntries.map((entry) => entry.key).join(" and ")} disagree; client authority aliases must be identical or only one may be set.`, usableUrlEntries.map((entry) => entry.key));
|
|
3526
|
+
}
|
|
3527
|
+
const envUrlHit = usableUrlEntries[0] ?? null;
|
|
3528
|
+
const keychainUrlHit = keychainConfigValue2(name, env, options.credentials?.keychain);
|
|
3529
|
+
const diskConfigUrlHit = appConfigDiskValue2(name, env, keys.apiUrlKeys);
|
|
3530
|
+
if (diskConfigUrlHit?.unusable) {
|
|
3531
|
+
throw new ClientTransportConfigurationError2(name, `${diskConfigUrlHit.key} in ${diskConfigUrlHit.path} is declared but blank or malformed; public clients require a valid HTTPS service authority.`, [diskConfigUrlHit.path]);
|
|
3532
|
+
}
|
|
3533
|
+
const urlCandidates = [
|
|
3534
|
+
...envUrlHit ? [envUrlHit] : [],
|
|
3535
|
+
...keychainUrlHit ? [{ key: keychainUrlHit.source, value: keychainUrlHit.value }] : [],
|
|
3536
|
+
...diskConfigUrlHit ? [{ key: diskConfigUrlHit.path, value: diskConfigUrlHit.value.trim() }] : []
|
|
3537
|
+
];
|
|
3538
|
+
const configuredUrl = urlCandidates[0] ?? null;
|
|
3539
|
+
const divergentUrls = urlCandidates.filter((candidate) => candidate.value !== configuredUrl?.value);
|
|
3540
|
+
if (configuredUrl && divergentUrls.length > 0) {
|
|
3541
|
+
throw new ClientTransportConfigurationError2(name, `${configuredUrl.key} and ${divergentUrls.map((candidate) => candidate.key).join(" and ")} select different service authorities; refusing to send a credential written for one authority to the other.`, urlCandidates.map((candidate) => candidate.key));
|
|
2536
3542
|
}
|
|
2537
|
-
|
|
2538
|
-
|
|
3543
|
+
const warnings = [];
|
|
3544
|
+
if (configuredUrl && !envUrlHit) {
|
|
3545
|
+
warnings.push(`No ${keys.apiUrlKeys[0]} in the environment; the server URL in ${configuredUrl.key} was used, so this client connects to the server. ` + `Keep that entry aligned with the intended service authority.`);
|
|
2539
3546
|
}
|
|
2540
|
-
const credential =
|
|
3547
|
+
const credential = resolveCredential2(name, env, options.credentials);
|
|
2541
3548
|
if (!credential) {
|
|
2542
|
-
const diskHint =
|
|
2543
|
-
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
transportSource: urlHit.key,
|
|
2547
|
-
baseUrl: null,
|
|
2548
|
-
apiUrlSource: urlHit.key,
|
|
2549
|
-
apiKeyPresent: false,
|
|
2550
|
-
apiKeySource: null,
|
|
2551
|
-
apiKeyTier: null,
|
|
2552
|
-
misconfigured: true,
|
|
2553
|
-
warning: warnings.join(" ")
|
|
2554
|
-
};
|
|
3549
|
+
const diskHint = credentialDiskSourcesForMessage2(name, env);
|
|
3550
|
+
const lead = configuredUrl ? `${configuredUrl.key} selects the HTTP server for '${name}', but no API key could be resolved` : `${keys.apiUrlKeys[0]} is not set and no API key could be resolved for '${name}'; a credential is required before the default fleet gateway authority applies`;
|
|
3551
|
+
warnings.push(`${lead}; refusing to create an unauthenticated client \u2014 public clients never fall back to SQLite or another local store. ` + `Looked in the Keychain (macOS only), then for a credential file at ${diskHint}, then for ${keys.apiKeyKeys[0]} in the environment.`);
|
|
3552
|
+
throw new ClientTransportConfigurationError2(name, warnings.join(" "), [configuredUrl?.key ?? keys.apiUrlKeys[0]]);
|
|
2555
3553
|
}
|
|
2556
3554
|
if (credential.warning)
|
|
2557
3555
|
warnings.push(credential.warning);
|
|
3556
|
+
let urlHit;
|
|
3557
|
+
if (configuredUrl) {
|
|
3558
|
+
urlHit = configuredUrl;
|
|
3559
|
+
} else {
|
|
3560
|
+
try {
|
|
3561
|
+
urlHit = { key: DEFAULT_AUTHORITY_SOURCE2, value: defaultFleetGatewayBaseUrl2(name) };
|
|
3562
|
+
} catch (error) {
|
|
3563
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
3564
|
+
throw new ClientTransportConfigurationError2(name, `No ${keys.apiUrlKeys[0]} is configured and the default fleet gateway authority cannot be composed for '${name}': ${message}`, [keys.apiUrlKeys[0]]);
|
|
3565
|
+
}
|
|
3566
|
+
}
|
|
2558
3567
|
const apiUrlSource = urlHit.key;
|
|
2559
3568
|
let baseUrl;
|
|
2560
3569
|
try {
|
|
2561
|
-
baseUrl =
|
|
3570
|
+
baseUrl = toV1BaseUrl2(urlHit.value);
|
|
2562
3571
|
} catch (error) {
|
|
2563
3572
|
const message = error instanceof Error ? error.message : String(error);
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
3573
|
+
throw new ClientTransportConfigurationError2(name, `Invalid API URL from ${apiUrlSource}: ${message}`, [apiUrlSource]);
|
|
3574
|
+
}
|
|
3575
|
+
return {
|
|
3576
|
+
resolution: {
|
|
3577
|
+
transport: "http",
|
|
2567
3578
|
transportSource: urlHit.key,
|
|
2568
|
-
baseUrl
|
|
2569
|
-
apiUrlSource
|
|
3579
|
+
baseUrl,
|
|
3580
|
+
apiUrlSource,
|
|
2570
3581
|
apiKeyPresent: true,
|
|
2571
3582
|
apiKeySource: credential.source,
|
|
2572
3583
|
apiKeyTier: credential.tier,
|
|
2573
|
-
misconfigured:
|
|
2574
|
-
warning: warnings.join(" ")
|
|
2575
|
-
}
|
|
2576
|
-
|
|
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
|
|
3584
|
+
misconfigured: false,
|
|
3585
|
+
warning: warnings.length > 0 ? warnings.join(" ") : null
|
|
3586
|
+
},
|
|
3587
|
+
credential
|
|
2587
3588
|
};
|
|
2588
3589
|
}
|
|
2589
|
-
function
|
|
2590
|
-
const paths =
|
|
2591
|
-
return paths.length > 0 ? paths.join(" or ") : "<no HOME set in this environment, so no credential file was consulted>";
|
|
3590
|
+
function credentialDiskSourcesForMessage2(name, env) {
|
|
3591
|
+
const paths = credentialDiskSources2(name, env);
|
|
3592
|
+
return paths.length > 0 ? paths.join(" or ") : "<no HOME or HASNA_HOME set in this environment, so no credential file was consulted>";
|
|
2592
3593
|
}
|
|
2593
3594
|
|
|
2594
3595
|
class HasnaHttpError extends Error {
|
|
2595
3596
|
status;
|
|
2596
3597
|
method;
|
|
2597
3598
|
path;
|
|
2598
|
-
body;
|
|
2599
3599
|
credentialSource;
|
|
2600
3600
|
credentialTier;
|
|
2601
3601
|
constructor(method, path, status, body, credential) {
|
|
@@ -2605,7 +3605,12 @@ class HasnaHttpError extends Error {
|
|
|
2605
3605
|
this.status = status;
|
|
2606
3606
|
this.method = method;
|
|
2607
3607
|
this.path = path;
|
|
2608
|
-
this
|
|
3608
|
+
Object.defineProperty(this, "body", {
|
|
3609
|
+
value: body,
|
|
3610
|
+
enumerable: status !== 401 && status !== 403,
|
|
3611
|
+
writable: false,
|
|
3612
|
+
configurable: false
|
|
3613
|
+
});
|
|
2609
3614
|
this.credentialSource = credential?.source ?? null;
|
|
2610
3615
|
this.credentialTier = credential?.tier ?? null;
|
|
2611
3616
|
}
|
|
@@ -2616,22 +3621,32 @@ function currentCredential(name, apiKey) {
|
|
|
2616
3621
|
}
|
|
2617
3622
|
return explicitCredential(name, apiKey);
|
|
2618
3623
|
}
|
|
3624
|
+
async function resolveRequestCredential(name, apiKey, env = process.env) {
|
|
3625
|
+
const resolved = currentCredential(name, apiKey);
|
|
3626
|
+
if (resolved.tier === "pointer") {
|
|
3627
|
+
return completePointerCredential(name, resolved, env);
|
|
3628
|
+
}
|
|
3629
|
+
return resolved;
|
|
3630
|
+
}
|
|
2619
3631
|
function authFailureGuidance(credential) {
|
|
2620
3632
|
const origin = `The API key for this request came from ${credential.source}`;
|
|
2621
3633
|
if (credential.deliberate) {
|
|
2622
3634
|
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
3635
|
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
3636
|
}
|
|
2625
|
-
if (credential.
|
|
3637
|
+
if (credential.tier === "env") {
|
|
2626
3638
|
const target = credential.diskCandidates[0];
|
|
2627
|
-
const remedy = target ? `
|
|
2628
|
-
return `${origin}, a variable in this process's environment
|
|
3639
|
+
const remedy = target ? `Store the CURRENT key in the Keychain or write it to ${target} \u2014 both are re-read on every call, so ` + `rotations take effect immediately and in every shell. Do not simply unset ${credential.source}: ` + `nothing was found in the Keychain or on disk, so that would leave this client with no credential at all.` : `This environment has no HOME or HASNA_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.`;
|
|
3640
|
+
return `${origin}, a variable in this process's environment. If a wrapper injected it for this one process, the ` + `wrapper re-reads its store on every invocation and the stored key itself is being rejected \u2014 rotate it. ` + `If this SHELL exported it, the export is a snapshot taken when the shell started: a STALE SHELL that ` + `exported the key before it was rotated keeps sending the old one until it exits. ${remedy}`;
|
|
3641
|
+
}
|
|
3642
|
+
if (credential.tier === "keychain") {
|
|
3643
|
+
return `${origin}, which was re-read from the Keychain on this very call \u2014 so a stale shell is NOT the cause ` + `here. The stored item is genuinely being rejected: update it with the current key, or re-run the fleet ` + `key distribution so this machine gets the current key.`;
|
|
2629
3644
|
}
|
|
2630
3645
|
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
3646
|
}
|
|
2632
3647
|
var DEFAULT_RETRY_STATUSES = [408, 425, 429, 500, 502, 503, 504];
|
|
2633
|
-
var
|
|
2634
|
-
var
|
|
3648
|
+
var IDEMPOTENT_METHODS2 = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]);
|
|
3649
|
+
var AUTHORITY_OVERRIDE_HEADERS2 = new Set([
|
|
2635
3650
|
"host",
|
|
2636
3651
|
":authority",
|
|
2637
3652
|
"forwarded",
|
|
@@ -2641,7 +3656,7 @@ var AUTHORITY_OVERRIDE_HEADERS = new Set([
|
|
|
2641
3656
|
function assertNoAuthorityOverrideHeaders(headers, source) {
|
|
2642
3657
|
if (!headers)
|
|
2643
3658
|
return;
|
|
2644
|
-
const forbidden = Object.keys(headers).find((name) =>
|
|
3659
|
+
const forbidden = Object.keys(headers).find((name) => AUTHORITY_OVERRIDE_HEADERS2.has(name.trim().toLowerCase()));
|
|
2645
3660
|
if (forbidden) {
|
|
2646
3661
|
throw new Error(`Authenticated ${source} headers must not set authority header '${forbidden}'.`);
|
|
2647
3662
|
}
|
|
@@ -2668,9 +3683,9 @@ function appendQuery(path, query) {
|
|
|
2668
3683
|
return `${path}${path.includes("?") ? "&" : "?"}${qs}`;
|
|
2669
3684
|
}
|
|
2670
3685
|
var defaultSleep = (ms) => new Promise((resolve3) => setTimeout(resolve3, ms));
|
|
2671
|
-
function
|
|
3686
|
+
function createHasnaHttpTransportInternal(options, requestBindingProvider) {
|
|
2672
3687
|
const fetchImpl = options.fetchImpl ?? ((input, init) => fetch(input, init));
|
|
2673
|
-
const base =
|
|
3688
|
+
const base = toV1BaseUrl2(options.baseUrl);
|
|
2674
3689
|
const timeoutMs = options.timeoutMs ?? 30000;
|
|
2675
3690
|
const sleep = options.sleepImpl ?? defaultSleep;
|
|
2676
3691
|
const defaultRetry = options.retry;
|
|
@@ -2730,13 +3745,20 @@ function createHasnaHttpTransport(options) {
|
|
|
2730
3745
|
if (opts.signal)
|
|
2731
3746
|
opts.signal.removeEventListener("abort", onAbort);
|
|
2732
3747
|
}
|
|
2733
|
-
const
|
|
3748
|
+
const authenticationFailure = response.status === 401 || response.status === 403;
|
|
2734
3749
|
let parsed = undefined;
|
|
2735
|
-
if (
|
|
3750
|
+
if (authenticationFailure) {
|
|
2736
3751
|
try {
|
|
2737
|
-
|
|
2738
|
-
} catch {
|
|
2739
|
-
|
|
3752
|
+
await response.body?.cancel();
|
|
3753
|
+
} catch {}
|
|
3754
|
+
} else {
|
|
3755
|
+
const text = await response.text();
|
|
3756
|
+
if (text.length > 0) {
|
|
3757
|
+
try {
|
|
3758
|
+
parsed = JSON.parse(text);
|
|
3759
|
+
} catch {
|
|
3760
|
+
parsed = text;
|
|
3761
|
+
}
|
|
2740
3762
|
}
|
|
2741
3763
|
}
|
|
2742
3764
|
if (!response.ok) {
|
|
@@ -2747,11 +3769,11 @@ function createHasnaHttpTransport(options) {
|
|
|
2747
3769
|
error: new HasnaHttpError(method, rel, response.status, parsed)
|
|
2748
3770
|
};
|
|
2749
3771
|
}
|
|
2750
|
-
if (
|
|
3772
|
+
if (authenticationFailure) {
|
|
2751
3773
|
return {
|
|
2752
3774
|
ok: false,
|
|
2753
3775
|
retryable: false,
|
|
2754
|
-
error: new HasnaHttpError(method, rel, response.status,
|
|
3776
|
+
error: new HasnaHttpError(method, rel, response.status, undefined, {
|
|
2755
3777
|
source: credential.source,
|
|
2756
3778
|
tier: credential.tier,
|
|
2757
3779
|
guidance: authFailureGuidance(credential)
|
|
@@ -2767,11 +3789,15 @@ function createHasnaHttpTransport(options) {
|
|
|
2767
3789
|
async function request(method, path, body, opts = {}) {
|
|
2768
3790
|
const upper = method.toUpperCase();
|
|
2769
3791
|
const rel = appendQuery(path.startsWith("/") ? path : `/${path}`, opts.query);
|
|
2770
|
-
const url = `${base}${rel}`;
|
|
2771
3792
|
const retry = resolveRetry(opts.retry);
|
|
2772
|
-
const methodRetryable =
|
|
3793
|
+
const methodRetryable = IDEMPOTENT_METHODS2.has(upper) || Boolean(opts.idempotencyKey);
|
|
2773
3794
|
const maxAttempts = retry && methodRetryable ? retry.retries + 1 : 1;
|
|
2774
|
-
const
|
|
3795
|
+
const binding = requestBindingProvider ? await requestBindingProvider() : {
|
|
3796
|
+
baseUrl: base,
|
|
3797
|
+
credential: await resolveRequestCredential(options.name, options.apiKey)
|
|
3798
|
+
};
|
|
3799
|
+
const url = `${binding.baseUrl}${rel}`;
|
|
3800
|
+
const credential = binding.credential;
|
|
2775
3801
|
let last = null;
|
|
2776
3802
|
for (let attempt = 1;attempt <= maxAttempts; attempt++) {
|
|
2777
3803
|
const result = await once(upper, rel, url, body, opts, credential);
|
|
@@ -2799,49 +3825,44 @@ function createHasnaHttpTransport(options) {
|
|
|
2799
3825
|
}
|
|
2800
3826
|
function createClientTransport(name, env = process.env, overrides) {
|
|
2801
3827
|
const credentialOptions = overrides?.credentials;
|
|
2802
|
-
const
|
|
2803
|
-
|
|
2804
|
-
|
|
2805
|
-
|
|
2806
|
-
|
|
2807
|
-
|
|
2808
|
-
|
|
2809
|
-
|
|
2810
|
-
|
|
2811
|
-
if (
|
|
2812
|
-
throw new
|
|
2813
|
-
}
|
|
2814
|
-
|
|
3828
|
+
const snapshotOptions = { ...credentialOptions ? { credentials: credentialOptions } : {} };
|
|
3829
|
+
const resolution = resolveClientTransportSnapshot2(name, env, snapshotOptions).resolution;
|
|
3830
|
+
const sameBinding = (left, right) => left.resolution.baseUrl === right.resolution.baseUrl && left.credential.apiKey === right.credential.apiKey && left.credential.pointerVaultKey === right.credential.pointerVaultKey && left.credential.source === right.credential.source && left.credential.tier === right.credential.tier;
|
|
3831
|
+
const unstableConfiguration = () => new ClientTransportConfigurationError2(name, "The configured service authority or credential changed while a request was being prepared; no authenticated request was sent.");
|
|
3832
|
+
const requestBindingProvider = async () => {
|
|
3833
|
+
const first = resolveClientTransportSnapshot2(name, env, snapshotOptions);
|
|
3834
|
+
const reviewed = resolveClientTransportSnapshot2(name, env, snapshotOptions);
|
|
3835
|
+
if (!sameBinding(first, reviewed))
|
|
3836
|
+
throw unstableConfiguration();
|
|
3837
|
+
if (reviewed.resolution.baseUrl !== resolution.baseUrl) {
|
|
3838
|
+
throw new ClientTransportConfigurationError2(name, "The configured service authority changed; rebuild the client before sending credentials.");
|
|
3839
|
+
}
|
|
3840
|
+
const credential = await resolveRequestCredential(name, () => reviewed.credential, env);
|
|
3841
|
+
const immediatelyBeforeDispatch = resolveClientTransportSnapshot2(name, env, snapshotOptions);
|
|
3842
|
+
if (!sameBinding(reviewed, immediatelyBeforeDispatch))
|
|
3843
|
+
throw unstableConfiguration();
|
|
3844
|
+
if (immediatelyBeforeDispatch.resolution.baseUrl !== resolution.baseUrl) {
|
|
3845
|
+
throw new ClientTransportConfigurationError2(name, "The configured service authority changed; rebuild the client before sending credentials.");
|
|
3846
|
+
}
|
|
3847
|
+
return { baseUrl: immediatelyBeforeDispatch.resolution.baseUrl, credential };
|
|
2815
3848
|
};
|
|
2816
3849
|
return {
|
|
2817
3850
|
transport: "http",
|
|
2818
|
-
client:
|
|
3851
|
+
client: createHasnaHttpTransportInternal({
|
|
2819
3852
|
name,
|
|
2820
3853
|
baseUrl: resolution.baseUrl,
|
|
2821
|
-
apiKey:
|
|
3854
|
+
apiKey: () => {
|
|
3855
|
+
throw new Error("The authenticated request binding provider was not invoked.");
|
|
3856
|
+
},
|
|
2822
3857
|
...overrides?.fetchImpl ? { fetchImpl: overrides.fetchImpl } : {},
|
|
2823
3858
|
...overrides?.headers ? { headers: overrides.headers } : {},
|
|
2824
3859
|
...overrides?.timeoutMs ? { timeoutMs: overrides.timeoutMs } : {},
|
|
2825
3860
|
...overrides?.retry !== undefined ? { retry: overrides.retry } : {},
|
|
2826
3861
|
...overrides?.sleepImpl ? { sleepImpl: overrides.sleepImpl } : {}
|
|
2827
|
-
}),
|
|
3862
|
+
}, requestBindingProvider),
|
|
2828
3863
|
resolution
|
|
2829
3864
|
};
|
|
2830
3865
|
}
|
|
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
3866
|
function resourcePath(resource) {
|
|
2846
3867
|
const trimmed = resource.replace(/^\/+|\/+$/g, "");
|
|
2847
3868
|
if (!trimmed)
|
|
@@ -2941,145 +3962,336 @@ function createHasnaStorageClient(name, transport) {
|
|
|
2941
3962
|
}
|
|
2942
3963
|
};
|
|
2943
3964
|
}
|
|
3965
|
+
function resolveStorageClient(name, env = process.env, overrides) {
|
|
3966
|
+
const wired = createClientTransport(name, env, overrides);
|
|
3967
|
+
return { transport: "http", client: createHasnaStorageClient(name, wired.client) };
|
|
3968
|
+
}
|
|
3969
|
+
|
|
3970
|
+
// src/lib/local-opt-in.ts
|
|
3971
|
+
var RECORDINGS_LOCAL_OPT_IN_ENV_KEYS = [
|
|
3972
|
+
"HASNA_RECORDINGS_LOCAL",
|
|
3973
|
+
"RECORDINGS_LOCAL"
|
|
3974
|
+
];
|
|
3975
|
+
function isRecordingsLocalOptIn(env = process.env) {
|
|
3976
|
+
return RECORDINGS_LOCAL_OPT_IN_ENV_KEYS.some((key) => (env[key] ?? "").trim() !== "");
|
|
3977
|
+
}
|
|
3978
|
+
var RECORDINGS_CARVED_LEGACY_ENV_KEYS = [
|
|
3979
|
+
"RECORDINGS_API_URL",
|
|
3980
|
+
"RECORDINGS_API_KEY"
|
|
3981
|
+
];
|
|
3982
|
+
function recordingsAuthorityEnvKeys() {
|
|
3983
|
+
const keys = clientTransportEnvKeys("recordings");
|
|
3984
|
+
return [
|
|
3985
|
+
...keys.apiUrlKeys,
|
|
3986
|
+
...keys.apiKeyKeys,
|
|
3987
|
+
credentialOverrideEnvKey("recordings"),
|
|
3988
|
+
credentialPointerEnvKey("recordings"),
|
|
3989
|
+
CREDENTIAL_PROFILE_ENV_KEY
|
|
3990
|
+
].filter((key) => !RECORDINGS_CARVED_LEGACY_ENV_KEYS.includes(key));
|
|
3991
|
+
}
|
|
3992
|
+
function hasRecordingsEnvAuthorityIntent(env = process.env) {
|
|
3993
|
+
return recordingsAuthorityEnvKeys().some((key) => (env[key] ?? "").trim() !== "");
|
|
3994
|
+
}
|
|
3995
|
+
function selectsRecordingsLocalStore(env = process.env) {
|
|
3996
|
+
return !hasRecordingsEnvAuthorityIntent(env) && isRecordingsLocalOptIn(env);
|
|
3997
|
+
}
|
|
3998
|
+
function recordingsResolverEnv(env) {
|
|
3999
|
+
const carved = RECORDINGS_CARVED_LEGACY_ENV_KEYS.filter((key) => (key in env));
|
|
4000
|
+
const blanks = recordingsAuthorityEnvKeys().filter((key) => (key in env) && (env[key] ?? "").trim() === "");
|
|
4001
|
+
const removed = new Set([...carved, ...blanks]);
|
|
4002
|
+
if (removed.size === 0)
|
|
4003
|
+
return env;
|
|
4004
|
+
const next = { ...env };
|
|
4005
|
+
for (const key of removed)
|
|
4006
|
+
delete next[key];
|
|
4007
|
+
return next;
|
|
4008
|
+
}
|
|
4009
|
+
var CONTRACTS_AMBIENT_ENVIRONMENT = Symbol.for("hasna:contracts:ambientClientEnvironment");
|
|
4010
|
+
function isAmbientRecordingsEnv(env) {
|
|
4011
|
+
if (typeof process !== "undefined" && env === process.env) {
|
|
4012
|
+
return true;
|
|
4013
|
+
}
|
|
4014
|
+
return env[CONTRACTS_AMBIENT_ENVIRONMENT] === true;
|
|
4015
|
+
}
|
|
4016
|
+
function recordingsResolverInputs(env, credentials = {}) {
|
|
4017
|
+
const normalised = recordingsResolverEnv(env);
|
|
4018
|
+
if (normalised === env)
|
|
4019
|
+
return { env: normalised, credentials };
|
|
4020
|
+
const keychain = { ...credentials.keychain };
|
|
4021
|
+
if (keychain.enabled === undefined && keychain.run === undefined) {
|
|
4022
|
+
keychain.enabled = isAmbientRecordingsEnv(env);
|
|
4023
|
+
}
|
|
4024
|
+
return { env: normalised, credentials: { ...credentials, keychain } };
|
|
4025
|
+
}
|
|
2944
4026
|
|
|
2945
4027
|
// src/http/client.ts
|
|
2946
|
-
|
|
2947
|
-
|
|
4028
|
+
var RETRY_STATUSES = new Set([408, 425, 429, 500, 502, 503, 504]);
|
|
4029
|
+
var IDEMPOTENT = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]);
|
|
4030
|
+
function throwRecordingsAuthorityFailure(error) {
|
|
4031
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
4032
|
+
const name = error instanceof Error ? error.name : "";
|
|
4033
|
+
if (name === "CredentialResolutionError" || name === "CredentialFileUnsafeError") {
|
|
4034
|
+
throw new Error(`REMOTE_API_CREDENTIAL_INVALID: ${message} There is no local fallback: ` + "local SQLite is opt-in only (HASNA_RECORDINGS_LOCAL=1) and is disabled by default \u2014 failing closed", { cause: error });
|
|
4035
|
+
}
|
|
4036
|
+
if (/no API key could be resolved/.test(message)) {
|
|
4037
|
+
if (/is not set and no API key could be resolved/.test(message)) {
|
|
4038
|
+
throw new Error("REMOTE_API_CONFIG_MISSING: no Recordings credential resolved from the Keychain item " + `hasna.credentials.recordings.api-key, ~/.hasna/recordings/config/credentials, or HASNA_RECORDINGS_API_KEY. ${message} ` + "There is no local fallback: local SQLite is opt-in only (HASNA_RECORDINGS_LOCAL=1, alias RECORDINGS_LOCAL=1) " + "and is disabled by default \u2014 failing closed instead of serving the local store", { cause: error });
|
|
4039
|
+
}
|
|
4040
|
+
throw new Error("REMOTE_API_KEY_MISSING: remote Recordings storage requires HASNA_RECORDINGS_API_KEY, the Keychain item " + `hasna.credentials.recordings.api-key, or ~/.hasna/recordings/config/credentials. ${message} ` + "There is no local fallback: local SQLite is opt-in only (HASNA_RECORDINGS_LOCAL=1) and is disabled by default \u2014 failing closed", { cause: error });
|
|
4041
|
+
}
|
|
4042
|
+
throw new Error(`REMOTE_API_URL_INVALID: ${message} local SQLite fallback is disabled`, { cause: error });
|
|
2948
4043
|
}
|
|
2949
|
-
function
|
|
2950
|
-
const token = envToken2(name);
|
|
4044
|
+
function toRecordsAuthority(resolution) {
|
|
2951
4045
|
return {
|
|
2952
|
-
|
|
2953
|
-
|
|
2954
|
-
|
|
4046
|
+
transport: resolution.transport,
|
|
4047
|
+
transportSource: resolution.transportSource,
|
|
4048
|
+
baseUrl: resolution.baseUrl,
|
|
4049
|
+
apiUrlSource: resolution.apiUrlSource,
|
|
4050
|
+
apiKeyPresent: resolution.apiKeyPresent,
|
|
4051
|
+
apiKeySource: resolution.apiKeySource,
|
|
4052
|
+
apiKeyTier: resolution.apiKeyTier,
|
|
4053
|
+
misconfigured: resolution.misconfigured,
|
|
4054
|
+
warning: resolution.warning
|
|
2955
4055
|
};
|
|
2956
4056
|
}
|
|
2957
|
-
function
|
|
2958
|
-
|
|
2959
|
-
|
|
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 };
|
|
4057
|
+
function resolveRecordingsTransport(env = process.env, options = {}) {
|
|
4058
|
+
if (selectsRecordingsLocalStore(env)) {
|
|
4059
|
+
return { transport: "sqlite", selected: false, source: "local-opt-in", authority: null };
|
|
2970
4060
|
}
|
|
2971
|
-
|
|
2972
|
-
|
|
2973
|
-
|
|
2974
|
-
|
|
2975
|
-
|
|
2976
|
-
|
|
4061
|
+
const resolverInputs = recordingsResolverInputs(env, options.credentials);
|
|
4062
|
+
let resolution;
|
|
4063
|
+
try {
|
|
4064
|
+
resolution = resolveClientTransport("recordings", resolverInputs.env, {
|
|
4065
|
+
credentials: resolverInputs.credentials
|
|
4066
|
+
});
|
|
4067
|
+
} catch (error) {
|
|
4068
|
+
throwRecordingsAuthorityFailure(error);
|
|
2977
4069
|
}
|
|
2978
|
-
|
|
2979
|
-
|
|
2980
|
-
|
|
2981
|
-
|
|
2982
|
-
|
|
2983
|
-
|
|
2984
|
-
return url.toString().replace(/\/+$/, "");
|
|
4070
|
+
return {
|
|
4071
|
+
transport: "http",
|
|
4072
|
+
selected: true,
|
|
4073
|
+
source: `${resolution.apiKeySource ?? resolution.apiKeyTier}+${resolution.apiUrlSource ?? "default"}`,
|
|
4074
|
+
authority: toRecordsAuthority(resolution)
|
|
4075
|
+
};
|
|
2985
4076
|
}
|
|
2986
|
-
function
|
|
2987
|
-
|
|
2988
|
-
|
|
2989
|
-
|
|
2990
|
-
|
|
2991
|
-
|
|
2992
|
-
|
|
2993
|
-
|
|
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];
|
|
4077
|
+
function getRecordingsTransportStatus(env = process.env, options = {}) {
|
|
4078
|
+
let resolution;
|
|
4079
|
+
try {
|
|
4080
|
+
resolution = resolveRecordingsTransport(env, options);
|
|
4081
|
+
} catch (error) {
|
|
4082
|
+
const issue = error instanceof Error ? error.message : String(error);
|
|
4083
|
+
const envKeys = clientTransportEnvKeys("recordings");
|
|
4084
|
+
const declared = (keys) => keys.some((key) => (env[key] ?? "").trim() !== "");
|
|
3002
4085
|
return {
|
|
3003
|
-
|
|
3004
|
-
|
|
3005
|
-
|
|
3006
|
-
|
|
3007
|
-
|
|
3008
|
-
|
|
3009
|
-
|
|
4086
|
+
selected: true,
|
|
4087
|
+
ok: false,
|
|
4088
|
+
transport: "invalid",
|
|
4089
|
+
api_url_configured: declared(envKeys.apiUrlKeys),
|
|
4090
|
+
api_key_configured: declared(envKeys.apiKeyKeys),
|
|
4091
|
+
api_url_source: null,
|
|
4092
|
+
api_key_source: null,
|
|
4093
|
+
api_key_tier: null,
|
|
4094
|
+
v1_base_url: null,
|
|
4095
|
+
issues: [issue],
|
|
4096
|
+
local_fallback: false
|
|
3010
4097
|
};
|
|
3011
4098
|
}
|
|
3012
|
-
if (
|
|
3013
|
-
return { transport: "sqlite", requested, modeSource, baseUrl: null, apiKeyPresent: Boolean(keyHit), misconfigured: false, warning: null };
|
|
3014
|
-
}
|
|
3015
|
-
if (!urlHit) {
|
|
4099
|
+
if (!resolution.selected) {
|
|
3016
4100
|
return {
|
|
3017
|
-
|
|
3018
|
-
|
|
3019
|
-
|
|
3020
|
-
|
|
3021
|
-
|
|
3022
|
-
|
|
3023
|
-
|
|
4101
|
+
selected: false,
|
|
4102
|
+
ok: true,
|
|
4103
|
+
transport: resolution.transport,
|
|
4104
|
+
api_url_configured: false,
|
|
4105
|
+
api_key_configured: false,
|
|
4106
|
+
api_url_source: null,
|
|
4107
|
+
api_key_source: null,
|
|
4108
|
+
api_key_tier: null,
|
|
4109
|
+
v1_base_url: null,
|
|
4110
|
+
issues: [],
|
|
4111
|
+
local_fallback: false
|
|
3024
4112
|
};
|
|
3025
4113
|
}
|
|
3026
|
-
|
|
3027
|
-
|
|
3028
|
-
|
|
3029
|
-
|
|
3030
|
-
|
|
3031
|
-
|
|
3032
|
-
|
|
3033
|
-
|
|
3034
|
-
|
|
3035
|
-
|
|
4114
|
+
const authority = resolution.authority;
|
|
4115
|
+
return {
|
|
4116
|
+
selected: true,
|
|
4117
|
+
ok: true,
|
|
4118
|
+
transport: resolution.transport,
|
|
4119
|
+
api_url_configured: authority.apiUrlSource !== null && authority.apiUrlSource !== "default",
|
|
4120
|
+
api_key_configured: authority.apiKeyPresent,
|
|
4121
|
+
api_url_source: authority.apiUrlSource,
|
|
4122
|
+
api_key_source: authority.apiKeySource,
|
|
4123
|
+
api_key_tier: authority.apiKeyTier,
|
|
4124
|
+
v1_base_url: authority.baseUrl,
|
|
4125
|
+
issues: [],
|
|
4126
|
+
local_fallback: false
|
|
4127
|
+
};
|
|
4128
|
+
}
|
|
4129
|
+
function resolveRecordingsCloudClient(env = process.env, options = {}) {
|
|
4130
|
+
if (selectsRecordingsLocalStore(env))
|
|
4131
|
+
return null;
|
|
4132
|
+
const resolverInputs = recordingsResolverInputs(env, options.credentials);
|
|
4133
|
+
let resolved;
|
|
4134
|
+
try {
|
|
4135
|
+
resolved = resolveStorageClient("recordings", resolverInputs.env, {
|
|
4136
|
+
fetchImpl: (input, init) => globalThis.fetch(input, { ...init, redirect: "manual" }),
|
|
4137
|
+
credentials: resolverInputs.credentials
|
|
4138
|
+
});
|
|
4139
|
+
} catch (error) {
|
|
4140
|
+
throwRecordingsAuthorityFailure(error);
|
|
3036
4141
|
}
|
|
3037
|
-
const
|
|
3038
|
-
|
|
4142
|
+
const client = resolved.client;
|
|
4143
|
+
return client;
|
|
4144
|
+
}
|
|
4145
|
+
|
|
4146
|
+
// src/store.ts
|
|
4147
|
+
import { createHash as createHash2, randomUUID as randomUUID5 } from "crypto";
|
|
4148
|
+
|
|
4149
|
+
// src/lib/audio-artifact-storage.ts
|
|
4150
|
+
import { GetObjectCommand, PutObjectCommand, S3Client as AwsS3Client } from "@aws-sdk/client-s3";
|
|
4151
|
+
import { createHash } from "crypto";
|
|
4152
|
+
import { readFile as readFile2 } from "fs/promises";
|
|
4153
|
+
import { extname } from "path";
|
|
4154
|
+
var SHA256_HEX = /^[0-9a-f]{64}$/;
|
|
4155
|
+
var DEFAULT_S3_PREFIX = "recordings";
|
|
4156
|
+
var AUDIO_CONTENT_TYPES = {
|
|
4157
|
+
wav: "audio/wav",
|
|
4158
|
+
mp3: "audio/mpeg",
|
|
4159
|
+
m4a: "audio/mp4",
|
|
4160
|
+
aac: "audio/aac",
|
|
4161
|
+
webm: "audio/webm",
|
|
4162
|
+
ogg: "audio/ogg",
|
|
4163
|
+
flac: "audio/flac"
|
|
4164
|
+
};
|
|
4165
|
+
function contentTypeForExtension(extension) {
|
|
4166
|
+
return AUDIO_CONTENT_TYPES[extension.toLowerCase()] ?? "application/octet-stream";
|
|
4167
|
+
}
|
|
4168
|
+
|
|
4169
|
+
class AudioArtifactStorage {
|
|
4170
|
+
bucket;
|
|
4171
|
+
prefix;
|
|
4172
|
+
s3;
|
|
4173
|
+
constructor(options = {}) {
|
|
4174
|
+
this.bucket = options.bucket;
|
|
4175
|
+
this.prefix = (options.prefix || DEFAULT_S3_PREFIX).replace(/^\/+|\/+$/g, "");
|
|
4176
|
+
this.s3 = this.bucket ? options.client ?? new AwsS3Client({ region: options.region || process.env.AWS_REGION || "us-east-1" }) : undefined;
|
|
4177
|
+
}
|
|
4178
|
+
get usesS3() {
|
|
4179
|
+
return Boolean(this.bucket);
|
|
4180
|
+
}
|
|
4181
|
+
objectKeyFor(recordingId, sha256, extension) {
|
|
4182
|
+
assertSha256(sha256);
|
|
4183
|
+
const safeExtension = extension.toLowerCase().match(/^[a-z0-9]+/)?.[0] || "bin";
|
|
4184
|
+
return `${this.prefix}/${recordingId}/${sha256}.${safeExtension}`;
|
|
4185
|
+
}
|
|
4186
|
+
async uploadAudio(recordingId, audioPath) {
|
|
4187
|
+
if (!this.bucket || !this.s3)
|
|
4188
|
+
return null;
|
|
4189
|
+
const bytes = await readFileBytes(audioPath);
|
|
4190
|
+
if (!bytes)
|
|
4191
|
+
return null;
|
|
4192
|
+
const sha256 = sha256Hex(bytes);
|
|
4193
|
+
const extension = extensionOf(audioPath);
|
|
4194
|
+
const key = this.objectKeyFor(recordingId, sha256, extension);
|
|
4195
|
+
await this.s3.send(new PutObjectCommand({
|
|
4196
|
+
Bucket: this.bucket,
|
|
4197
|
+
Key: key,
|
|
4198
|
+
Body: bytes,
|
|
4199
|
+
ContentType: contentTypeForExtension(extension)
|
|
4200
|
+
}));
|
|
4201
|
+
return { objectKey: key, sha256, bytes: bytes.byteLength };
|
|
4202
|
+
}
|
|
4203
|
+
async readAudio(objectKey) {
|
|
4204
|
+
if (!this.bucket || !this.s3)
|
|
4205
|
+
return null;
|
|
4206
|
+
try {
|
|
4207
|
+
const response = await this.s3.send(new GetObjectCommand({ Bucket: this.bucket, Key: objectKey }));
|
|
4208
|
+
return await bodyToBytes(response.Body);
|
|
4209
|
+
} catch {
|
|
4210
|
+
return null;
|
|
4211
|
+
}
|
|
4212
|
+
}
|
|
4213
|
+
}
|
|
4214
|
+
function resolveAudioArtifactStorage(env = process.env, client) {
|
|
4215
|
+
const bucket = env.HASNA_RECORDINGS_S3_BUCKET || env.RECORDINGS_S3_BUCKET || undefined;
|
|
4216
|
+
const prefix = env.HASNA_RECORDINGS_S3_PREFIX || env.RECORDINGS_S3_PREFIX || undefined;
|
|
4217
|
+
const region = env.RECORDINGS_S3_REGION || env.AWS_REGION || undefined;
|
|
4218
|
+
return new AudioArtifactStorage({ bucket, prefix, region, client });
|
|
4219
|
+
}
|
|
4220
|
+
async function uploadAudioAtCreation(recordingId, audioPath, storage = resolveAudioArtifactStorage()) {
|
|
4221
|
+
if (!audioPath)
|
|
4222
|
+
return null;
|
|
4223
|
+
if (!storage.usesS3)
|
|
4224
|
+
return null;
|
|
3039
4225
|
try {
|
|
3040
|
-
|
|
4226
|
+
const uploaded = await storage.uploadAudio(recordingId, audioPath);
|
|
4227
|
+
if (!uploaded)
|
|
4228
|
+
return null;
|
|
4229
|
+
console.warn(`recordings: uploaded audio for ${recordingId} to ${uploaded.objectKey}`);
|
|
4230
|
+
return uploaded;
|
|
3041
4231
|
} catch (error) {
|
|
3042
|
-
|
|
3043
|
-
return
|
|
4232
|
+
console.warn(`recordings: audio upload failed for ${recordingId} (${String(error instanceof Error ? error.message : error)}); keeping local audio_path only`);
|
|
4233
|
+
return null;
|
|
3044
4234
|
}
|
|
3045
|
-
return { transport: "http", requested, modeSource, baseUrl, apiKeyPresent: true, misconfigured: false, warning: null };
|
|
3046
4235
|
}
|
|
3047
|
-
|
|
3048
|
-
|
|
3049
|
-
|
|
3050
|
-
|
|
3051
|
-
|
|
3052
|
-
|
|
3053
|
-
|
|
3054
|
-
|
|
3055
|
-
|
|
3056
|
-
|
|
3057
|
-
|
|
3058
|
-
|
|
3059
|
-
|
|
3060
|
-
|
|
3061
|
-
|
|
3062
|
-
|
|
3063
|
-
|
|
3064
|
-
|
|
3065
|
-
|
|
3066
|
-
|
|
4236
|
+
function assertSha256(value) {
|
|
4237
|
+
if (!SHA256_HEX.test(value)) {
|
|
4238
|
+
throw new Error("audio digest must be a lowercase hex sha-256; refusing to build a storage key from it");
|
|
4239
|
+
}
|
|
4240
|
+
}
|
|
4241
|
+
function sha256Hex(bytes) {
|
|
4242
|
+
const hash = createHash("sha256");
|
|
4243
|
+
hash.update(bytes);
|
|
4244
|
+
return hash.digest("hex");
|
|
4245
|
+
}
|
|
4246
|
+
async function readFileBytes(path) {
|
|
4247
|
+
try {
|
|
4248
|
+
return new Uint8Array(await readFile2(path));
|
|
4249
|
+
} catch {
|
|
4250
|
+
return null;
|
|
4251
|
+
}
|
|
4252
|
+
}
|
|
4253
|
+
function extensionOf(path) {
|
|
4254
|
+
const extension = extname(path).replace(/^\./, "").toLowerCase();
|
|
4255
|
+
return extension || "bin";
|
|
4256
|
+
}
|
|
4257
|
+
async function bodyToBytes(body) {
|
|
4258
|
+
if (!body)
|
|
4259
|
+
return new Uint8Array(0);
|
|
4260
|
+
if (body instanceof Uint8Array)
|
|
4261
|
+
return body;
|
|
4262
|
+
if (typeof body.transformToByteArray === "function") {
|
|
4263
|
+
return new Uint8Array(await body.transformToByteArray());
|
|
4264
|
+
}
|
|
4265
|
+
if (typeof body.getReader === "function") {
|
|
4266
|
+
const reader = body.getReader();
|
|
4267
|
+
const chunks2 = [];
|
|
4268
|
+
for (;; ) {
|
|
4269
|
+
const { value, done } = await reader.read();
|
|
4270
|
+
if (done)
|
|
4271
|
+
break;
|
|
4272
|
+
if (value)
|
|
4273
|
+
chunks2.push(value);
|
|
3067
4274
|
}
|
|
3068
|
-
|
|
4275
|
+
return concat(chunks2);
|
|
3069
4276
|
}
|
|
3070
|
-
|
|
3071
|
-
|
|
4277
|
+
const chunks = [];
|
|
4278
|
+
for await (const chunk of body) {
|
|
4279
|
+
chunks.push(typeof chunk === "string" ? new TextEncoder().encode(chunk) : chunk);
|
|
3072
4280
|
}
|
|
3073
|
-
|
|
3074
|
-
|
|
3075
|
-
|
|
4281
|
+
return concat(chunks);
|
|
4282
|
+
}
|
|
4283
|
+
function concat(chunks) {
|
|
4284
|
+
const total = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0);
|
|
4285
|
+
const merged = new Uint8Array(new ArrayBuffer(total));
|
|
4286
|
+
let offset = 0;
|
|
4287
|
+
for (const chunk of chunks) {
|
|
4288
|
+
merged.set(chunk, offset);
|
|
4289
|
+
offset += chunk.byteLength;
|
|
3076
4290
|
}
|
|
3077
|
-
return
|
|
4291
|
+
return merged;
|
|
3078
4292
|
}
|
|
3079
4293
|
|
|
3080
4294
|
// src/store.ts
|
|
3081
|
-
import { createHash, randomUUID as randomUUID5 } from "crypto";
|
|
3082
|
-
var APP = "recordings";
|
|
3083
4295
|
function listQuery(filter) {
|
|
3084
4296
|
if (!filter)
|
|
3085
4297
|
return {};
|
|
@@ -3109,7 +4321,15 @@ var localStore = {
|
|
|
3109
4321
|
mode: "sqlite",
|
|
3110
4322
|
baseUrl: null,
|
|
3111
4323
|
async createRecording(input, idempotencyKey) {
|
|
3112
|
-
return withLocalStoreReaderLease(() =>
|
|
4324
|
+
return withLocalStoreReaderLease(async () => {
|
|
4325
|
+
const recording = createRecording(input, undefined, idempotencyKey);
|
|
4326
|
+
if (!recording.audio_path)
|
|
4327
|
+
return recording;
|
|
4328
|
+
const uploaded = await uploadAudioAtCreation(recording.id, recording.audio_path, localArtifactStorageFor());
|
|
4329
|
+
if (!uploaded)
|
|
4330
|
+
return recording;
|
|
4331
|
+
return updateRecordingAudio(recording.id, uploaded.objectKey, uploaded.sha256, uploaded.bytes) ?? recording;
|
|
4332
|
+
});
|
|
3113
4333
|
},
|
|
3114
4334
|
async getRecording(id) {
|
|
3115
4335
|
return withLocalStoreReaderLease(() => getRecording(id));
|
|
@@ -3303,7 +4523,7 @@ function apiStore(client) {
|
|
|
3303
4523
|
};
|
|
3304
4524
|
}
|
|
3305
4525
|
function recordingPageFingerprint(items) {
|
|
3306
|
-
const hash =
|
|
4526
|
+
const hash = createHash2("sha256");
|
|
3307
4527
|
const ids = items.map((item) => String(item.id ?? "")).sort();
|
|
3308
4528
|
for (const id of ids) {
|
|
3309
4529
|
hash.update(String(id.length));
|
|
@@ -3339,19 +4559,23 @@ async function countStoreRecordings(store, filter) {
|
|
|
3339
4559
|
throw new Error(`Legacy Store exceeded ${maxPageRequests} pages while counting recordings`);
|
|
3340
4560
|
}
|
|
3341
4561
|
var cached = null;
|
|
3342
|
-
|
|
3343
|
-
|
|
4562
|
+
var localArtifactStorageOverride;
|
|
4563
|
+
function localArtifactStorageFor() {
|
|
4564
|
+
return localArtifactStorageOverride ?? resolveAudioArtifactStorage();
|
|
4565
|
+
}
|
|
4566
|
+
function getStore(env = process.env, options = {}) {
|
|
4567
|
+
if (env === process.env && cached && options.credentials === undefined)
|
|
3344
4568
|
return cached;
|
|
3345
|
-
const
|
|
3346
|
-
const store =
|
|
3347
|
-
if (env === process.env)
|
|
4569
|
+
const client = resolveRecordingsCloudClient(env, options);
|
|
4570
|
+
const store = client ? apiStore(client) : localStore;
|
|
4571
|
+
if (env === process.env && options.credentials === undefined)
|
|
3348
4572
|
cached = store;
|
|
3349
4573
|
return store;
|
|
3350
4574
|
}
|
|
3351
4575
|
|
|
3352
4576
|
// src/lib/recorder.ts
|
|
3353
4577
|
import { spawn as spawn2 } from "child_process";
|
|
3354
|
-
import { join as
|
|
4578
|
+
import { join as join6 } from "path";
|
|
3355
4579
|
import { existsSync as existsSync3 } from "fs";
|
|
3356
4580
|
|
|
3357
4581
|
// src/types/index.ts
|
|
@@ -3402,7 +4626,7 @@ function startRecording(config) {
|
|
|
3402
4626
|
}
|
|
3403
4627
|
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
3404
4628
|
const filename = `recording-${timestamp}.${config.audio_format}`;
|
|
3405
|
-
const filepath =
|
|
4629
|
+
const filepath = join6(config.audio_dir, filename);
|
|
3406
4630
|
const args = buildRecordArgs(filepath, config);
|
|
3407
4631
|
const [command, ...commandArgs] = args;
|
|
3408
4632
|
if (command === undefined) {
|
|
@@ -3474,7 +4698,7 @@ function buildRecordArgs(filepath, config) {
|
|
|
3474
4698
|
async function recordDuration(seconds, config) {
|
|
3475
4699
|
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
3476
4700
|
const filename = `recording-${timestamp}.${config.audio_format}`;
|
|
3477
|
-
const filepath =
|
|
4701
|
+
const filepath = join6(config.audio_dir, filename);
|
|
3478
4702
|
const args = [
|
|
3479
4703
|
"rec",
|
|
3480
4704
|
"-r",
|
|
@@ -3512,7 +4736,7 @@ var _client = null;
|
|
|
3512
4736
|
var _clientApiKey = null;
|
|
3513
4737
|
function getClient(config) {
|
|
3514
4738
|
if (!config.openai_api_key) {
|
|
3515
|
-
throw new TranscriptionError("OpenAI API key not configured. Set OPENAI_API_KEY env var
|
|
4739
|
+
throw new TranscriptionError("OpenAI API key not configured. Set OPENAI_API_KEY env var (e.g. " + "'secrets exec <key> --as OPENAI_API_KEY -- recordings \u2026')");
|
|
3516
4740
|
}
|
|
3517
4741
|
if (_client && _clientApiKey === config.openai_api_key)
|
|
3518
4742
|
return _client;
|
|
@@ -3537,7 +4761,7 @@ async function verifyTranscriptionCredential(config, model, options = {}) {
|
|
|
3537
4761
|
status: null,
|
|
3538
4762
|
model,
|
|
3539
4763
|
role,
|
|
3540
|
-
message: `no API key configured for the ${role} role. ` + "Set OPENAI_API_KEY env var
|
|
4764
|
+
message: `no API key configured for the ${role} role. ` + "Set OPENAI_API_KEY env var (e.g. 'secrets exec <key> --as OPENAI_API_KEY -- recordings \u2026')"
|
|
3541
4765
|
};
|
|
3542
4766
|
}
|
|
3543
4767
|
try {
|
|
@@ -3646,9 +4870,9 @@ ${trimmed}`;
|
|
|
3646
4870
|
}
|
|
3647
4871
|
|
|
3648
4872
|
// src/lib/capture-probe.ts
|
|
3649
|
-
import { spawnSync as
|
|
3650
|
-
import { existsSync as existsSync4, readFileSync as
|
|
3651
|
-
import { join as
|
|
4873
|
+
import { spawnSync as spawnSync4 } from "child_process";
|
|
4874
|
+
import { existsSync as existsSync4, readFileSync as readFileSync4, rmSync as rmSync2 } from "fs";
|
|
4875
|
+
import { join as join7 } from "path";
|
|
3652
4876
|
import { tmpdir } from "os";
|
|
3653
4877
|
|
|
3654
4878
|
// src/lib/macos-bundle.ts
|
|
@@ -3664,7 +4888,7 @@ var WAVE_FORMAT_PCM = 1;
|
|
|
3664
4888
|
var WAVE_FORMAT_EXTENSIBLE = 65534;
|
|
3665
4889
|
var SUBFORMAT_OFFSET_IN_EXTENSION = 8;
|
|
3666
4890
|
function readWavPeak(filepath) {
|
|
3667
|
-
const buf =
|
|
4891
|
+
const buf = readFileSync4(filepath);
|
|
3668
4892
|
if (buf.length < RIFF_HEADER_BYTES) {
|
|
3669
4893
|
throw new Error(`not a RIFF file (${buf.length} bytes): ${filepath}`);
|
|
3670
4894
|
}
|
|
@@ -3739,9 +4963,9 @@ function probeMicrophoneCapture(config, options = {}) {
|
|
|
3739
4963
|
peak: 0,
|
|
3740
4964
|
silent: null
|
|
3741
4965
|
};
|
|
3742
|
-
const filepath =
|
|
4966
|
+
const filepath = join7(tmpdir(), `recordings-capture-probe-${process.pid}-${Date.now()}.wav`);
|
|
3743
4967
|
try {
|
|
3744
|
-
const result =
|
|
4968
|
+
const result = spawnSync4(executable, [
|
|
3745
4969
|
"-q",
|
|
3746
4970
|
"-r",
|
|
3747
4971
|
String(config.sample_rate),
|
|
@@ -3839,37 +5063,37 @@ function captureProbeSubject(env = process.env, options = {}) {
|
|
|
3839
5063
|
const termProgram = env.TERM_PROGRAM?.trim();
|
|
3840
5064
|
const hasTty = options.hasTty ?? Boolean(process.stdin.isTTY || process.stdout.isTTY);
|
|
3841
5065
|
if (overSsh) {
|
|
3842
|
-
const subject2 = "the SSH session (sshd), not
|
|
5066
|
+
const subject2 = "the SSH session (sshd), not Hasna Recordings.app";
|
|
3843
5067
|
return {
|
|
3844
5068
|
headless: true,
|
|
3845
5069
|
subject_known: true,
|
|
3846
5070
|
subject: subject2,
|
|
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
|
|
5071
|
+
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 Hasna Recordings.app can record. Judge the app by its own TCC entry and its log."
|
|
3848
5072
|
};
|
|
3849
5073
|
}
|
|
3850
5074
|
if (inTmux) {
|
|
3851
|
-
const subject2 = "the tmux server, not
|
|
5075
|
+
const subject2 = "the tmux server, not Hasna Recordings.app and not this pane's shell";
|
|
3852
5076
|
return {
|
|
3853
5077
|
headless: false,
|
|
3854
5078
|
subject_known: true,
|
|
3855
5079
|
subject: subject2,
|
|
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 " + "
|
|
5080
|
+
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 " + "Hasna Recordings.app and about whether anyone could have been prompted."
|
|
3857
5081
|
};
|
|
3858
5082
|
}
|
|
3859
5083
|
if (!termProgram && !hasTty) {
|
|
3860
5084
|
return {
|
|
3861
5085
|
headless: true,
|
|
3862
5086
|
subject_known: false,
|
|
3863
|
-
subject: "an unidentified responsible process (not
|
|
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
|
|
5087
|
+
subject: "an unidentified responsible process (not Hasna Recordings.app)",
|
|
5088
|
+
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 Hasna Recordings.app, and nothing here can be shown a consent prompt. Inconclusive."
|
|
3865
5089
|
};
|
|
3866
5090
|
}
|
|
3867
|
-
const subject = `${termProgram || "the terminal application running this command"}, not
|
|
5091
|
+
const subject = `${termProgram || "the terminal application running this command"}, not Hasna Recordings.app`;
|
|
3868
5092
|
return {
|
|
3869
5093
|
headless: false,
|
|
3870
5094
|
subject_known: Boolean(termProgram),
|
|
3871
5095
|
subject,
|
|
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
|
|
5096
|
+
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 Hasna Recordings.app, which needs its own grant."
|
|
3873
5097
|
};
|
|
3874
5098
|
}
|
|
3875
5099
|
var TCC_UNREADABLE_STATE = TCC_DATABASE_UNREADABLE_STATE;
|
|
@@ -3899,7 +5123,7 @@ function microphoneGrantInstruction(options) {
|
|
|
3899
5123
|
const bundlePath = candidates[0] ?? null;
|
|
3900
5124
|
const steps = [];
|
|
3901
5125
|
if (!bundlePath) {
|
|
3902
|
-
steps.push("No
|
|
5126
|
+
steps.push("No Hasna Recordings.app bundle was found on disk, so there is nothing to grant Microphone to yet. " + "Install the app first ('recordings app install').");
|
|
3903
5127
|
return {
|
|
3904
5128
|
bundle_path: null,
|
|
3905
5129
|
bundle_identifier: RECORDINGS_BUNDLE_IDENTIFIER,
|
|
@@ -3908,10 +5132,10 @@ function microphoneGrantInstruction(options) {
|
|
|
3908
5132
|
};
|
|
3909
5133
|
}
|
|
3910
5134
|
if (candidates.length > 1) {
|
|
3911
|
-
steps.push(`AMBIGUOUS: ${candidates.length}
|
|
5135
|
+
steps.push(`AMBIGUOUS: ${candidates.length} Hasna Recordings.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.");
|
|
3912
5136
|
}
|
|
3913
5137
|
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.`);
|
|
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 ${
|
|
5138
|
+
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 ${join7(bundlePath, "Contents", "MacOS", "Recordings")}.`);
|
|
3915
5139
|
if (options.requestState === "never_requested") {
|
|
3916
5140
|
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.");
|
|
3917
5141
|
} else if (options.requestState === "unknown") {
|
|
@@ -3930,7 +5154,7 @@ function microphoneGrantInstruction(options) {
|
|
|
3930
5154
|
// src/lib/persistence-probe.ts
|
|
3931
5155
|
import { Database as Database2 } from "bun:sqlite";
|
|
3932
5156
|
import { existsSync as existsSync5 } from "fs";
|
|
3933
|
-
var
|
|
5157
|
+
var ENV_CREDENTIAL_MODE_SOURCE = "env";
|
|
3934
5158
|
function safeBaseUrl(baseUrl) {
|
|
3935
5159
|
if (!baseUrl)
|
|
3936
5160
|
return baseUrl;
|
|
@@ -3974,42 +5198,51 @@ function localStoreIsBehindSchema(dbPath) {
|
|
|
3974
5198
|
return null;
|
|
3975
5199
|
}
|
|
3976
5200
|
}
|
|
3977
|
-
function describeActiveStore(config, env = process.env) {
|
|
5201
|
+
function describeActiveStore(config, env = process.env, options = {}) {
|
|
3978
5202
|
const localDbPath = config.db_path;
|
|
3979
5203
|
const localDbPresent = existsSync5(localDbPath);
|
|
3980
|
-
const
|
|
3981
|
-
|
|
3982
|
-
|
|
3983
|
-
resolution = resolveTransport(APP, env);
|
|
3984
|
-
} catch (error) {
|
|
5204
|
+
const status = getRecordingsTransportStatus(env, options);
|
|
5205
|
+
if (!status.ok) {
|
|
5206
|
+
const issues = status.issues.length > 0 ? status.issues.join(" ") : null;
|
|
3985
5207
|
return {
|
|
3986
|
-
transport: "
|
|
5208
|
+
transport: "none",
|
|
3987
5209
|
mode_source: "unresolved",
|
|
3988
5210
|
base_url: null,
|
|
3989
5211
|
local_db_path: localDbPath,
|
|
3990
5212
|
local_db_present: localDbPresent,
|
|
3991
|
-
local_db_recordings:
|
|
5213
|
+
local_db_recordings: null,
|
|
5214
|
+
divergent: false,
|
|
5215
|
+
warning: issues ? redactKeyMaterial(issues) : null
|
|
5216
|
+
};
|
|
5217
|
+
}
|
|
5218
|
+
if (!status.selected) {
|
|
5219
|
+
return {
|
|
5220
|
+
transport: "sqlite",
|
|
5221
|
+
mode_source: "local-opt-in",
|
|
5222
|
+
base_url: null,
|
|
5223
|
+
local_db_path: localDbPath,
|
|
5224
|
+
local_db_present: localDbPresent,
|
|
5225
|
+
local_db_recordings: localDbPresent ? readLocalRecordingCount(localDbPath) : null,
|
|
3992
5226
|
divergent: false,
|
|
3993
|
-
warning:
|
|
5227
|
+
warning: null
|
|
3994
5228
|
};
|
|
3995
5229
|
}
|
|
3996
|
-
const
|
|
5230
|
+
const localDbRecordings = localDbPresent ? readLocalRecordingCount(localDbPath) : null;
|
|
5231
|
+
const divergent = (localDbRecordings ?? 0) > 0;
|
|
3997
5232
|
const warnings = [];
|
|
3998
|
-
if (
|
|
3999
|
-
warnings.push(
|
|
4000
|
-
if (resolution.transport === "http" && localDbPresent && localDbRecordings === null) {
|
|
4001
|
-
warnings.push(`writes go to ${safeBaseUrl(resolution.baseUrl)}, and ${localDbPath} exists but could not be counted, ` + "so whether a second dataset is sitting there is UNKNOWN \u2014 not 'none'.");
|
|
5233
|
+
if (status.api_key_tier === ENV_CREDENTIAL_MODE_SOURCE) {
|
|
5234
|
+
warnings.push("the credential came from the process environment (HASNA_RECORDINGS_API_KEY): a shell export is a " + "snapshot taken when the shell started, so a rotation stays invisible until that shell exits. " + "Check `launchctl getenv HASNA_RECORDINGS_API_KEY` too: a launchd session variable " + "is inherited by the GUI app as well as by shells, and is invisible in a login profile. " + "Prefer the Keychain item hasna.credentials.recordings.api-key or " + "~/.hasna/recordings/config/credentials, both re-read on every call.");
|
|
4002
5235
|
}
|
|
4003
|
-
if (
|
|
4004
|
-
warnings.push(`writes go to ${safeBaseUrl(
|
|
5236
|
+
if (localDbPresent && localDbRecordings === null) {
|
|
5237
|
+
warnings.push(`writes go to ${safeBaseUrl(status.v1_base_url)}, and ${localDbPath} exists but could not be counted, ` + "so whether a second dataset is sitting there is UNKNOWN \u2014 not 'none'.");
|
|
4005
5238
|
}
|
|
4006
|
-
if (
|
|
4007
|
-
warnings.push(
|
|
5239
|
+
if (divergent) {
|
|
5240
|
+
warnings.push(`writes go to ${safeBaseUrl(status.v1_base_url)}, 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.");
|
|
4008
5241
|
}
|
|
4009
5242
|
return {
|
|
4010
|
-
transport:
|
|
4011
|
-
mode_source:
|
|
4012
|
-
base_url: safeBaseUrl(
|
|
5243
|
+
transport: "http",
|
|
5244
|
+
mode_source: `${status.api_key_source ?? status.api_key_tier}+${status.api_url_source ?? "default"}`,
|
|
5245
|
+
base_url: safeBaseUrl(status.v1_base_url),
|
|
4013
5246
|
local_db_path: localDbPath,
|
|
4014
5247
|
local_db_present: localDbPresent,
|
|
4015
5248
|
local_db_recordings: localDbRecordings,
|
|
@@ -4017,6 +5250,20 @@ function describeActiveStore(config, env = process.env) {
|
|
|
4017
5250
|
warning: warnings.length > 0 ? warnings.join(" ") : null
|
|
4018
5251
|
};
|
|
4019
5252
|
}
|
|
5253
|
+
function activeStoreFailClosed(description) {
|
|
5254
|
+
return description.transport === "none";
|
|
5255
|
+
}
|
|
5256
|
+
function describeActiveStoreLine(description, paint) {
|
|
5257
|
+
if (activeStoreFailClosed(description)) {
|
|
5258
|
+
const refusal = description.warning ? ` (${description.warning})` : "";
|
|
5259
|
+
const file = description.local_db_present ? `${description.local_db_path} is present but NOT opened` : `${description.local_db_path} absent`;
|
|
5260
|
+
return paint.fail(`\u2717 Active store: none \u2014 fail-closed${refusal}; ${file}`);
|
|
5261
|
+
}
|
|
5262
|
+
if (description.transport === "sqlite") {
|
|
5263
|
+
return paint.pass(`\u2713 Active store: sqlite \u2192 ${description.local_db_path} (selected by ${description.mode_source})`);
|
|
5264
|
+
}
|
|
5265
|
+
return paint.pass(`\u2713 Active store: http \u2192 ${description.base_url ?? "?"} (selected by ${description.mode_source})`);
|
|
5266
|
+
}
|
|
4020
5267
|
var PROBE_SEVERITY_GLYPHS = {
|
|
4021
5268
|
pass: "\u2713",
|
|
4022
5269
|
warn: "?",
|
|
@@ -4443,13 +5690,13 @@ ${block}` : block;
|
|
|
4443
5690
|
}
|
|
4444
5691
|
|
|
4445
5692
|
// src/cli/macos-permissions.ts
|
|
4446
|
-
import { spawnSync as
|
|
4447
|
-
import { existsSync as existsSync6, mkdtempSync, rmSync as rmSync3, statSync as
|
|
5693
|
+
import { spawnSync as spawnSync5 } from "child_process";
|
|
5694
|
+
import { existsSync as existsSync6, mkdtempSync, rmSync as rmSync3, statSync as statSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
4448
5695
|
import { tmpdir as tmpdir2 } from "os";
|
|
4449
|
-
import { join as
|
|
4450
|
-
var defaultPermissionHelperRunner = (executable, arguments_, options) =>
|
|
5696
|
+
import { join as join8 } from "path";
|
|
5697
|
+
var defaultPermissionHelperRunner = (executable, arguments_, options) => spawnSync5(executable, arguments_, options);
|
|
4451
5698
|
function runMacOSPermissionRequest(appPath, runner = defaultPermissionHelperRunner) {
|
|
4452
|
-
const executable =
|
|
5699
|
+
const executable = join8(appPath, "Contents", "MacOS", "Recordings");
|
|
4453
5700
|
const result = runner(executable, ["--request-permissions", "--open-permission-settings"], { stdio: "inherit" });
|
|
4454
5701
|
return {
|
|
4455
5702
|
exitCode: result.error ? 1 : result.status ?? 1,
|
|
@@ -4460,8 +5707,8 @@ var CODESIGN_REQUIREMENT_SATISFIED_STATUS = 0;
|
|
|
4460
5707
|
var CODESIGN_REQUIREMENT_UNSATISFIED_STATUS = 3;
|
|
4461
5708
|
function tccDatabasePaths(home) {
|
|
4462
5709
|
return [
|
|
4463
|
-
|
|
4464
|
-
|
|
5710
|
+
join8(home, "Library", "Application Support", "com.apple.TCC", "TCC.db"),
|
|
5711
|
+
join8("/", "Library", "Application Support", "com.apple.TCC", "TCC.db")
|
|
4465
5712
|
];
|
|
4466
5713
|
}
|
|
4467
5714
|
function tccAuthValueLabel(value) {
|
|
@@ -4478,7 +5725,7 @@ function tccAuthValueLabel(value) {
|
|
|
4478
5725
|
return `unknown(${value})`;
|
|
4479
5726
|
}
|
|
4480
5727
|
}
|
|
4481
|
-
var defaultCodesignRequirementRunner = (requirementPath, appPath) =>
|
|
5728
|
+
var defaultCodesignRequirementRunner = (requirementPath, appPath) => spawnSync5("/usr/bin/codesign", ["--verify", "--verbose=2", "-R", requirementPath, appPath], { encoding: "utf8", stdio: ["ignore", "ignore", "ignore"] });
|
|
4482
5729
|
function verifyStoredRequirementWithCodesign(csreqHex, appPath, runner = defaultCodesignRequirementRunner, bundleExists = existsSync6) {
|
|
4483
5730
|
const normalized = csreqHex.trim();
|
|
4484
5731
|
if (normalized.length === 0 || normalized.length % 2 !== 0 || !/^[0-9a-fA-F]+$/.test(normalized)) {
|
|
@@ -4488,8 +5735,8 @@ function verifyStoredRequirementWithCodesign(csreqHex, appPath, runner = default
|
|
|
4488
5735
|
return "unverifiable";
|
|
4489
5736
|
let scratchDirectory = null;
|
|
4490
5737
|
try {
|
|
4491
|
-
scratchDirectory = mkdtempSync(
|
|
4492
|
-
const requirementPath =
|
|
5738
|
+
scratchDirectory = mkdtempSync(join8(tmpdir2(), "recordings-tcc-csreq-"));
|
|
5739
|
+
const requirementPath = join8(scratchDirectory, "tcc-requirement.bin");
|
|
4493
5740
|
writeFileSync2(requirementPath, Buffer.from(normalized, "hex"));
|
|
4494
5741
|
const result = runner(requirementPath, appPath);
|
|
4495
5742
|
if (result.error)
|
|
@@ -4509,7 +5756,7 @@ function verifyStoredRequirementWithCodesign(csreqHex, appPath, runner = default
|
|
|
4509
5756
|
var defaultTccPermissionProbe = {
|
|
4510
5757
|
databasePresence: (dbPath) => {
|
|
4511
5758
|
try {
|
|
4512
|
-
|
|
5759
|
+
statSync2(dbPath);
|
|
4513
5760
|
return "present";
|
|
4514
5761
|
} catch (error) {
|
|
4515
5762
|
return error.code === "ENOENT" ? "absent" : "indeterminate";
|
|
@@ -4517,7 +5764,7 @@ var defaultTccPermissionProbe = {
|
|
|
4517
5764
|
},
|
|
4518
5765
|
readAccessRow: (dbPath, service) => {
|
|
4519
5766
|
const sql = "select ifnull(auth_value, '') || '|' || ifnull(hex(csreq), '') from access where service = '" + service.replace(/'/g, "''") + "' and client = '" + RECORDINGS_BUNDLE_IDENTIFIER + "' and client_type = 0 order by last_modified desc limit 1;";
|
|
4520
|
-
const result =
|
|
5767
|
+
const result = spawnSync5("/usr/bin/sqlite3", [dbPath, sql], {
|
|
4521
5768
|
encoding: "utf8",
|
|
4522
5769
|
stdio: ["ignore", "pipe", "pipe"]
|
|
4523
5770
|
});
|
|
@@ -4538,7 +5785,7 @@ var defaultTccPermissionProbe = {
|
|
|
4538
5785
|
verifyStoredRequirement: (csreqHex, appPath) => verifyStoredRequirementWithCodesign(csreqHex, appPath),
|
|
4539
5786
|
describeStoredRequirement: (csreqHex) => describeStoredRequirementWithCsreq(csreqHex)
|
|
4540
5787
|
};
|
|
4541
|
-
function describeStoredRequirementWithCsreq(csreqHex, runner = (requirementPath) =>
|
|
5788
|
+
function describeStoredRequirementWithCsreq(csreqHex, runner = (requirementPath) => spawnSync5("/usr/bin/csreq", ["-r", requirementPath, "-t"], {
|
|
4542
5789
|
encoding: "utf8",
|
|
4543
5790
|
stdio: ["ignore", "pipe", "ignore"]
|
|
4544
5791
|
})) {
|
|
@@ -4548,8 +5795,8 @@ function describeStoredRequirementWithCsreq(csreqHex, runner = (requirementPath)
|
|
|
4548
5795
|
}
|
|
4549
5796
|
let scratchDirectory = null;
|
|
4550
5797
|
try {
|
|
4551
|
-
scratchDirectory = mkdtempSync(
|
|
4552
|
-
const requirementPath =
|
|
5798
|
+
scratchDirectory = mkdtempSync(join8(tmpdir2(), "recordings-tcc-decode-"));
|
|
5799
|
+
const requirementPath = join8(scratchDirectory, "tcc-requirement.bin");
|
|
4553
5800
|
writeFileSync2(requirementPath, Buffer.from(normalized, "hex"));
|
|
4554
5801
|
const result = runner(requirementPath);
|
|
4555
5802
|
if (result.error || result.status !== 0)
|
|
@@ -4638,7 +5885,7 @@ function describeTccAuthorizationSubject(appPath) {
|
|
|
4638
5885
|
}
|
|
4639
5886
|
|
|
4640
5887
|
// src/cli/macos-shortcut.ts
|
|
4641
|
-
import { spawnSync as
|
|
5888
|
+
import { spawnSync as spawnSync6 } from "child_process";
|
|
4642
5889
|
import { existsSync as existsSync7 } from "fs";
|
|
4643
5890
|
var TRIGGER_DEFAULTS_EXECUTABLE = process.platform === "darwin" ? "/usr/bin/defaults" : process.env.RECORDINGS_TEST_DEFAULTS_EXECUTABLE ?? null;
|
|
4644
5891
|
var RECORDINGS_BUNDLE_ID = RECORDINGS_BUNDLE_IDENTIFIER;
|
|
@@ -4845,14 +6092,14 @@ var defaultBundleIdentifierReader = (bundlePath) => {
|
|
|
4845
6092
|
if (!existsSync7(`${bundlePath}/Contents/Info.plist`) && !existsSync7(`${bundlePath}/Contents/Info`)) {
|
|
4846
6093
|
return null;
|
|
4847
6094
|
}
|
|
4848
|
-
const result =
|
|
6095
|
+
const result = spawnSync6(TRIGGER_DEFAULTS_EXECUTABLE, ["read", `${bundlePath}/Contents/Info`, "CFBundleIdentifier"], { encoding: "utf8" });
|
|
4849
6096
|
if (result.status !== 0)
|
|
4850
6097
|
return null;
|
|
4851
6098
|
return result.stdout.trim() || null;
|
|
4852
6099
|
};
|
|
4853
6100
|
var PROCESS_LISTER_EXECUTABLE = process.platform === "darwin" ? "/bin/ps" : process.env.RECORDINGS_TEST_PS_EXECUTABLE ?? "/bin/ps";
|
|
4854
6101
|
var defaultProcessLister = () => {
|
|
4855
|
-
const result =
|
|
6102
|
+
const result = spawnSync6(PROCESS_LISTER_EXECUTABLE, ["-Awwo", "comm="], { encoding: "utf8" });
|
|
4856
6103
|
if (result.status !== 0)
|
|
4857
6104
|
return null;
|
|
4858
6105
|
return result.stdout;
|
|
@@ -4897,7 +6144,7 @@ function isThisApp(candidate, readBundleIdentifier) {
|
|
|
4897
6144
|
function readDefault(key) {
|
|
4898
6145
|
if (TRIGGER_DEFAULTS_EXECUTABLE === null)
|
|
4899
6146
|
return null;
|
|
4900
|
-
const result =
|
|
6147
|
+
const result = spawnSync6(TRIGGER_DEFAULTS_EXECUTABLE, ["read", RECORDINGS_BUNDLE_ID, key], {
|
|
4901
6148
|
encoding: "utf8"
|
|
4902
6149
|
});
|
|
4903
6150
|
if (result.status !== 0)
|
|
@@ -4908,7 +6155,7 @@ function writeDefault(key, value) {
|
|
|
4908
6155
|
if (TRIGGER_DEFAULTS_EXECUTABLE === null) {
|
|
4909
6156
|
throw new Error(`cannot write ${key}: this machine has no readable app UserDefaults domain`);
|
|
4910
6157
|
}
|
|
4911
|
-
const result =
|
|
6158
|
+
const result = spawnSync6(TRIGGER_DEFAULTS_EXECUTABLE, ["write", RECORDINGS_BUNDLE_ID, key, "-string", value], {
|
|
4912
6159
|
encoding: "utf8"
|
|
4913
6160
|
});
|
|
4914
6161
|
if (result.status !== 0) {
|
|
@@ -4944,14 +6191,14 @@ function writeUseFnKey(enabled) {
|
|
|
4944
6191
|
if (TRIGGER_DEFAULTS_EXECUTABLE === null) {
|
|
4945
6192
|
throw new Error(`cannot write ${USE_FN_KEY_DEFAULTS_KEY}: this machine has no readable app UserDefaults domain`);
|
|
4946
6193
|
}
|
|
4947
|
-
const result =
|
|
6194
|
+
const result = spawnSync6(TRIGGER_DEFAULTS_EXECUTABLE, ["write", RECORDINGS_BUNDLE_ID, USE_FN_KEY_DEFAULTS_KEY, "-bool", enabled ? "true" : "false"], { encoding: "utf8" });
|
|
4948
6195
|
if (result.status !== 0) {
|
|
4949
6196
|
throw new Error(`defaults write ${USE_FN_KEY_DEFAULTS_KEY} failed: ${result.stderr?.trim() || `exit ${result.status}`}`);
|
|
4950
6197
|
}
|
|
4951
6198
|
}
|
|
4952
6199
|
|
|
4953
6200
|
// src/cli/trigger-probe.ts
|
|
4954
|
-
import { closeSync, openSync, readSync, statSync as
|
|
6201
|
+
import { closeSync as closeSync3, openSync as openSync3, readSync, statSync as statSync3 } from "fs";
|
|
4955
6202
|
var FN_BLOCKING_ACCESSIBILITY_STATES = [
|
|
4956
6203
|
"denied",
|
|
4957
6204
|
"stale_allowed_for_previous_app_build"
|
|
@@ -5070,12 +6317,12 @@ function readAppLogTail(logPath, maxBytes = APP_LOG_TAIL_BYTES) {
|
|
|
5070
6317
|
return null;
|
|
5071
6318
|
let handle = null;
|
|
5072
6319
|
try {
|
|
5073
|
-
const size =
|
|
6320
|
+
const size = statSync3(logPath).size;
|
|
5074
6321
|
if (size === 0)
|
|
5075
6322
|
return null;
|
|
5076
6323
|
const length = Math.min(size, maxBytes);
|
|
5077
6324
|
const buffer = Buffer.allocUnsafe(length);
|
|
5078
|
-
handle =
|
|
6325
|
+
handle = openSync3(logPath, "r");
|
|
5079
6326
|
const read = readSync(handle, buffer, 0, length, size - length);
|
|
5080
6327
|
return buffer.subarray(0, read).toString("utf8");
|
|
5081
6328
|
} catch {
|
|
@@ -5083,7 +6330,7 @@ function readAppLogTail(logPath, maxBytes = APP_LOG_TAIL_BYTES) {
|
|
|
5083
6330
|
} finally {
|
|
5084
6331
|
if (handle !== null) {
|
|
5085
6332
|
try {
|
|
5086
|
-
|
|
6333
|
+
closeSync3(handle);
|
|
5087
6334
|
} catch {}
|
|
5088
6335
|
}
|
|
5089
6336
|
}
|
|
@@ -5190,10 +6437,10 @@ function currentMachineId(env = process.env, hostName = hostname()) {
|
|
|
5190
6437
|
}
|
|
5191
6438
|
|
|
5192
6439
|
// src/lib/bun-runtime.ts
|
|
5193
|
-
import { accessSync, constants as fsConstants, realpathSync as realpathSync3, statSync as
|
|
5194
|
-
import { isAbsolute as
|
|
6440
|
+
import { accessSync, constants as fsConstants, realpathSync as realpathSync3, statSync as statSync4 } from "fs";
|
|
6441
|
+
import { isAbsolute as isAbsolute4 } from "path";
|
|
5195
6442
|
import { randomBytes } from "crypto";
|
|
5196
|
-
import { spawnSync as
|
|
6443
|
+
import { spawnSync as spawnSync7 } from "child_process";
|
|
5197
6444
|
var BUN_VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
5198
6445
|
var INSTALLER_ENVIRONMENT_KEYS = [
|
|
5199
6446
|
"HOME",
|
|
@@ -5207,19 +6454,19 @@ var INSTALLER_ENVIRONMENT_KEYS = [
|
|
|
5207
6454
|
];
|
|
5208
6455
|
var INSTALLER_PATH = "/usr/bin:/bin:/usr/sbin:/sbin";
|
|
5209
6456
|
function validateBunExecutable(candidate) {
|
|
5210
|
-
if (!
|
|
6457
|
+
if (!isAbsolute4(candidate))
|
|
5211
6458
|
return { reason: "path is not absolute" };
|
|
5212
6459
|
let executable;
|
|
5213
6460
|
try {
|
|
5214
6461
|
executable = realpathSync3(candidate);
|
|
5215
|
-
if (!
|
|
6462
|
+
if (!statSync4(executable).isFile())
|
|
5216
6463
|
return { reason: "resolved path is not a regular file" };
|
|
5217
6464
|
accessSync(executable, fsConstants.X_OK);
|
|
5218
6465
|
} catch {
|
|
5219
6466
|
return { reason: "path is missing, inaccessible, or not executable" };
|
|
5220
6467
|
}
|
|
5221
6468
|
const nonce = randomBytes(24).toString("hex");
|
|
5222
|
-
const probe =
|
|
6469
|
+
const probe = spawnSync7(executable, [
|
|
5223
6470
|
"-e",
|
|
5224
6471
|
`
|
|
5225
6472
|
import { realpathSync, statSync } from "node:fs";
|
|
@@ -5283,20 +6530,20 @@ function createInstallerEnvironment(environment, bunExecutable) {
|
|
|
5283
6530
|
}
|
|
5284
6531
|
|
|
5285
6532
|
// src/lib/release-install-policy.ts
|
|
5286
|
-
import { createHash as
|
|
6533
|
+
import { createHash as createHash3 } from "crypto";
|
|
5287
6534
|
import {
|
|
5288
6535
|
chmodSync as chmodSync2,
|
|
5289
|
-
closeSync as
|
|
6536
|
+
closeSync as closeSync4,
|
|
5290
6537
|
constants,
|
|
5291
|
-
fstatSync,
|
|
6538
|
+
fstatSync as fstatSync3,
|
|
5292
6539
|
fsyncSync,
|
|
5293
6540
|
mkdtempSync as mkdtempSync2,
|
|
5294
|
-
openSync as
|
|
5295
|
-
readFileSync as
|
|
6541
|
+
openSync as openSync4,
|
|
6542
|
+
readFileSync as readFileSync5,
|
|
5296
6543
|
rmSync as rmSync4,
|
|
5297
6544
|
writeFileSync as writeFileSync3
|
|
5298
6545
|
} from "fs";
|
|
5299
|
-
import { isAbsolute as
|
|
6546
|
+
import { isAbsolute as isAbsolute5, join as join9 } from "path";
|
|
5300
6547
|
var LOWER_SHA256 = /^[a-f0-9]{64}$/;
|
|
5301
6548
|
var LOWER_SOURCE_SHA = /^[a-f0-9]{40}$/;
|
|
5302
6549
|
var RELEASE_VERSION = /^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$/;
|
|
@@ -5356,7 +6603,7 @@ function prepareReleaseInstallInputs(input) {
|
|
|
5356
6603
|
throw new Error("Team ID must be 10 uppercase alphanumeric characters");
|
|
5357
6604
|
}
|
|
5358
6605
|
const manifestBytes = readBoundedRegularFile(input.manifestPath, "manifest", MAX_MANIFEST_BYTES);
|
|
5359
|
-
const actualManifestSha256 =
|
|
6606
|
+
const actualManifestSha256 = createHash3("sha256").update(manifestBytes).digest("hex");
|
|
5360
6607
|
if (actualManifestSha256 !== input.manifestSha256) {
|
|
5361
6608
|
throw new Error("manifest does not match the operator-approved SHA-256");
|
|
5362
6609
|
}
|
|
@@ -5366,10 +6613,10 @@ function prepareReleaseInstallInputs(input) {
|
|
|
5366
6613
|
assertReleaseManifest(manifest, input);
|
|
5367
6614
|
assertReleaseEnvelope(envelope, manifest, manifestBytes.byteLength, input);
|
|
5368
6615
|
const snapshotRoot = input.snapshotRoot ?? "/private/tmp";
|
|
5369
|
-
if (!
|
|
6616
|
+
if (!isAbsolute5(snapshotRoot)) {
|
|
5370
6617
|
throw new Error("release snapshot root must be absolute");
|
|
5371
6618
|
}
|
|
5372
|
-
const snapshotDirectory = mkdtempSync2(
|
|
6619
|
+
const snapshotDirectory = mkdtempSync2(join9(snapshotRoot, "recordings-release-install."));
|
|
5373
6620
|
let cleaned = false;
|
|
5374
6621
|
const cleanup = () => {
|
|
5375
6622
|
if (cleaned)
|
|
@@ -5381,9 +6628,9 @@ function prepareReleaseInstallInputs(input) {
|
|
|
5381
6628
|
rmSync4(snapshotDirectory, { recursive: true, force: true });
|
|
5382
6629
|
};
|
|
5383
6630
|
try {
|
|
5384
|
-
const manifestSnapshot =
|
|
5385
|
-
const envelopeDigest =
|
|
5386
|
-
const envelopeSnapshot =
|
|
6631
|
+
const manifestSnapshot = join9(snapshotDirectory, `${input.manifestSha256}.manifest.json`);
|
|
6632
|
+
const envelopeDigest = createHash3("sha256").update(envelopeBytes).digest("hex");
|
|
6633
|
+
const envelopeSnapshot = join9(snapshotDirectory, `${envelopeDigest}.envelope.json`);
|
|
5387
6634
|
writeSnapshot(manifestSnapshot, manifestBytes);
|
|
5388
6635
|
writeSnapshot(envelopeSnapshot, envelopeBytes);
|
|
5389
6636
|
chmodSync2(snapshotDirectory, 320);
|
|
@@ -5394,19 +6641,19 @@ function prepareReleaseInstallInputs(input) {
|
|
|
5394
6641
|
}
|
|
5395
6642
|
}
|
|
5396
6643
|
function assertAbsolutePath(value, label) {
|
|
5397
|
-
if (!
|
|
6644
|
+
if (!isAbsolute5(value))
|
|
5398
6645
|
throw new Error(`${label} path must be absolute`);
|
|
5399
6646
|
}
|
|
5400
6647
|
function readBoundedRegularFile(path, label, maximum) {
|
|
5401
|
-
const descriptor =
|
|
6648
|
+
const descriptor = openSync4(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
5402
6649
|
try {
|
|
5403
|
-
const metadata =
|
|
6650
|
+
const metadata = fstatSync3(descriptor);
|
|
5404
6651
|
if (!metadata.isFile() || metadata.size < 1 || metadata.size > maximum) {
|
|
5405
6652
|
throw new Error(`${label} must be a non-empty bounded regular file`);
|
|
5406
6653
|
}
|
|
5407
|
-
return
|
|
6654
|
+
return readFileSync5(descriptor);
|
|
5408
6655
|
} finally {
|
|
5409
|
-
|
|
6656
|
+
closeSync4(descriptor);
|
|
5410
6657
|
}
|
|
5411
6658
|
}
|
|
5412
6659
|
function parseObject(bytes, label) {
|
|
@@ -5486,22 +6733,22 @@ function isObject(value) {
|
|
|
5486
6733
|
}
|
|
5487
6734
|
function writeSnapshot(path, bytes) {
|
|
5488
6735
|
writeFileSync3(path, bytes, { flag: "wx", mode: 256 });
|
|
5489
|
-
const descriptor =
|
|
6736
|
+
const descriptor = openSync4(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
5490
6737
|
try {
|
|
5491
6738
|
fsyncSync(descriptor);
|
|
5492
6739
|
} finally {
|
|
5493
|
-
|
|
6740
|
+
closeSync4(descriptor);
|
|
5494
6741
|
}
|
|
5495
6742
|
}
|
|
5496
6743
|
|
|
5497
6744
|
// src/cli/desktop-snapshot.ts
|
|
5498
|
-
import { chmodSync as chmodSync3, mkdirSync as mkdirSync4, mkdtempSync as mkdtempSync3, renameSync as renameSync2, rmSync as rmSync5, statSync as
|
|
5499
|
-
import { dirname as dirname4, join as
|
|
5500
|
-
import { spawnSync as
|
|
6745
|
+
import { chmodSync as chmodSync3, mkdirSync as mkdirSync4, mkdtempSync as mkdtempSync3, renameSync as renameSync2, rmSync as rmSync5, statSync as statSync5 } from "fs";
|
|
6746
|
+
import { dirname as dirname4, join as join10, resolve as resolve3 } from "path";
|
|
6747
|
+
import { spawnSync as spawnSync8 } from "child_process";
|
|
5501
6748
|
var SCREEN_CAPTURE_EXECUTABLE = "/usr/sbin/screencapture";
|
|
5502
6749
|
var DEFAULT_DESKTOP_SNAPSHOT = "desktop-snapshot.png";
|
|
5503
6750
|
function runScreenCapture(executable, arguments_) {
|
|
5504
|
-
const result =
|
|
6751
|
+
const result = spawnSync8(executable, arguments_, {
|
|
5505
6752
|
encoding: "utf8",
|
|
5506
6753
|
stdio: ["ignore", "ignore", "pipe"]
|
|
5507
6754
|
});
|
|
@@ -5518,9 +6765,9 @@ function exportDesktopSnapshot(output = DEFAULT_DESKTOP_SNAPSHOT, dependencies =
|
|
|
5518
6765
|
const destination = resolve3(dependencies.cwd ?? process.cwd(), output);
|
|
5519
6766
|
const destinationDirectory = dirname4(destination);
|
|
5520
6767
|
mkdirSync4(destinationDirectory, { recursive: true });
|
|
5521
|
-
const stagingDirectory = mkdtempSync3(
|
|
6768
|
+
const stagingDirectory = mkdtempSync3(join10(destinationDirectory, ".recordings-desktop-"));
|
|
5522
6769
|
chmodSync3(stagingDirectory, 448);
|
|
5523
|
-
const stagingPath =
|
|
6770
|
+
const stagingPath = join10(stagingDirectory, "snapshot.png");
|
|
5524
6771
|
try {
|
|
5525
6772
|
const capture = dependencies.capture ?? runScreenCapture;
|
|
5526
6773
|
const result = capture(SCREEN_CAPTURE_EXECUTABLE, [
|
|
@@ -5540,7 +6787,7 @@ function exportDesktopSnapshot(output = DEFAULT_DESKTOP_SNAPSHOT, dependencies =
|
|
|
5540
6787
|
}
|
|
5541
6788
|
let size = 0;
|
|
5542
6789
|
try {
|
|
5543
|
-
const snapshot =
|
|
6790
|
+
const snapshot = statSync5(stagingPath);
|
|
5544
6791
|
if (snapshot.isFile())
|
|
5545
6792
|
size = snapshot.size;
|
|
5546
6793
|
} catch {}
|
|
@@ -5643,7 +6890,7 @@ Output:`));
|
|
|
5643
6890
|
})
|
|
5644
6891
|
});
|
|
5645
6892
|
if (parentOpts.json) {
|
|
5646
|
-
|
|
6893
|
+
await printJSON(recording);
|
|
5647
6894
|
} else {
|
|
5648
6895
|
console.log(chalk.dim(`
|
|
5649
6896
|
Saved as ${recording.id.slice(0, 8)}`));
|
|
@@ -5695,7 +6942,7 @@ program.command("transcribe <file>").description("Transcribe an existing audio f
|
|
|
5695
6942
|
})
|
|
5696
6943
|
}, recordingId);
|
|
5697
6944
|
if (parentOpts.json) {
|
|
5698
|
-
|
|
6945
|
+
await printJSON(recording);
|
|
5699
6946
|
} else if (processed.mode === "enhanced") {
|
|
5700
6947
|
console.log(chalk.green("Enhanced:"));
|
|
5701
6948
|
console.log(processed.text);
|
|
@@ -5750,7 +6997,7 @@ program.command("save-text [text]").description("Save already-transcribed text a
|
|
|
5750
6997
|
metadata
|
|
5751
6998
|
}, recordingId);
|
|
5752
6999
|
if (parentOpts.json) {
|
|
5753
|
-
|
|
7000
|
+
await printJSON(recording);
|
|
5754
7001
|
} else if (processed.mode === "enhanced") {
|
|
5755
7002
|
console.log(processed.text);
|
|
5756
7003
|
} else {
|
|
@@ -5772,11 +7019,11 @@ ${text}`;
|
|
|
5772
7019
|
try {
|
|
5773
7020
|
const result = await enhanceText(text, instruction, config);
|
|
5774
7021
|
if (parentOpts.json) {
|
|
5775
|
-
|
|
7022
|
+
await printJSON({
|
|
5776
7023
|
raw_text: text,
|
|
5777
7024
|
processed_text: result.enhanced,
|
|
5778
7025
|
model_used: result.model
|
|
5779
|
-
}
|
|
7026
|
+
});
|
|
5780
7027
|
} else {
|
|
5781
7028
|
console.log(result.enhanced);
|
|
5782
7029
|
}
|
|
@@ -5815,7 +7062,7 @@ program.command("save <text>").description("Save raw text as a recording (no aud
|
|
|
5815
7062
|
machine_id: currentMachineId()
|
|
5816
7063
|
});
|
|
5817
7064
|
if (parentOpts.json) {
|
|
5818
|
-
|
|
7065
|
+
await printJSON(recording);
|
|
5819
7066
|
} else {
|
|
5820
7067
|
console.log(chalk.green(`\u2713 Saved recording ${recording.id}`));
|
|
5821
7068
|
}
|
|
@@ -5842,7 +7089,7 @@ program.command("list").description("List recordings in compact form").option("-
|
|
|
5842
7089
|
const store = getStore();
|
|
5843
7090
|
const recordings = await store.listRecordings(filter);
|
|
5844
7091
|
if (parentOpts.json) {
|
|
5845
|
-
|
|
7092
|
+
await printJSON(recordings);
|
|
5846
7093
|
return;
|
|
5847
7094
|
}
|
|
5848
7095
|
const total = await countStoreRecordings(store, withoutPagination(filter));
|
|
@@ -5874,7 +7121,7 @@ program.command("search <query>").description("Search recordings by text content
|
|
|
5874
7121
|
const store = getStore();
|
|
5875
7122
|
const results = await store.searchRecordings(query, filter);
|
|
5876
7123
|
if (parentOpts.json) {
|
|
5877
|
-
|
|
7124
|
+
await printJSON(results);
|
|
5878
7125
|
return;
|
|
5879
7126
|
}
|
|
5880
7127
|
const total = await countStoreRecordings(store, withoutPagination({ ...filter, search: query }));
|
|
@@ -5900,7 +7147,7 @@ program.command("stats").description("Show recording statistics").action(async (
|
|
|
5900
7147
|
const parentOpts = program.opts();
|
|
5901
7148
|
const stats = await getStore().getRecordingStats();
|
|
5902
7149
|
if (parentOpts.json) {
|
|
5903
|
-
|
|
7150
|
+
await printJSON(stats);
|
|
5904
7151
|
return;
|
|
5905
7152
|
}
|
|
5906
7153
|
console.log(chalk.bold(`Recording Statistics
|
|
@@ -5926,7 +7173,7 @@ program.command("agents").description("List registered agents").option("-n, --li
|
|
|
5926
7173
|
const agents = await getStore().listAgents();
|
|
5927
7174
|
const page = parentOpts.json ? maybePageJson(agents, pagination, opts) : pageItems(agents, pagination);
|
|
5928
7175
|
if (parentOpts.json) {
|
|
5929
|
-
|
|
7176
|
+
await printJSON(page);
|
|
5930
7177
|
return;
|
|
5931
7178
|
}
|
|
5932
7179
|
if (page.length === 0) {
|
|
@@ -5953,7 +7200,7 @@ projectCommand.command("register").description("Register a project in the active
|
|
|
5953
7200
|
const parentOpts = program.opts();
|
|
5954
7201
|
const project = await getStore().registerProject(opts.name, opts.path, opts.description);
|
|
5955
7202
|
if (parentOpts.json) {
|
|
5956
|
-
|
|
7203
|
+
await printJSON(project);
|
|
5957
7204
|
return;
|
|
5958
7205
|
}
|
|
5959
7206
|
console.log(`${chalk.cyan(truncateText(project.id, 80))} ${chalk.bold(truncateText(project.name, 80))} \u2014 ${truncatePath(project.path, 120)}`);
|
|
@@ -5964,7 +7211,7 @@ program.command("projects").description("List registered projects").option("-n,
|
|
|
5964
7211
|
const projects = await getStore().listProjects();
|
|
5965
7212
|
const page = parentOpts.json ? maybePageJson(projects, pagination, opts) : pageItems(projects, pagination);
|
|
5966
7213
|
if (parentOpts.json) {
|
|
5967
|
-
|
|
7214
|
+
await printJSON(page);
|
|
5968
7215
|
return;
|
|
5969
7216
|
}
|
|
5970
7217
|
if (page.length === 0) {
|
|
@@ -5989,10 +7236,10 @@ program.command("projects").description("List registered projects").option("-n,
|
|
|
5989
7236
|
});
|
|
5990
7237
|
program.command("init").description("Initialize .recordings/ in current directory").action(() => {
|
|
5991
7238
|
const { mkdirSync: mkdirSync5, writeFileSync: writeFileSync4, existsSync: existsSync9 } = __require("fs");
|
|
5992
|
-
const { join:
|
|
5993
|
-
const dir =
|
|
5994
|
-
const audioDir =
|
|
5995
|
-
const configFile =
|
|
7239
|
+
const { join: join11 } = __require("path");
|
|
7240
|
+
const dir = join11(process.cwd(), ".recordings");
|
|
7241
|
+
const audioDir = join11(dir, "audio");
|
|
7242
|
+
const configFile = join11(dir, "config.json");
|
|
5996
7243
|
mkdirSync5(audioDir, { recursive: true });
|
|
5997
7244
|
if (!existsSync9(configFile)) {
|
|
5998
7245
|
const defaultConf = {
|
|
@@ -6015,9 +7262,9 @@ program.command("init").description("Initialize .recordings/ in current director
|
|
|
6015
7262
|
console.log(chalk.dim(" db: .recordings/recordings.db"));
|
|
6016
7263
|
});
|
|
6017
7264
|
var appCommand = program.command("app").description("Manage the macOS app installed from this package");
|
|
6018
|
-
appCommand.command("install").description("Install a release or explicitly approved local-only
|
|
7265
|
+
appCommand.command("install").description("Install a release or explicitly approved local-only Hasna Recordings.app artifact").requiredOption("--artifact <path>", "Finalized Hasna Recordings.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) => {
|
|
6019
7266
|
if (process.platform !== "darwin") {
|
|
6020
|
-
console.error(chalk.red("
|
|
7267
|
+
console.error(chalk.red("Hasna Recordings.app installation is only supported on macOS"));
|
|
6021
7268
|
process.exit(1);
|
|
6022
7269
|
}
|
|
6023
7270
|
if (opts.variant !== "full" && opts.variant !== "bar") {
|
|
@@ -6037,7 +7284,7 @@ appCommand.command("install").description("Install a release or explicitly appro
|
|
|
6037
7284
|
try {
|
|
6038
7285
|
assertReleaseOnlyOptions(opts);
|
|
6039
7286
|
if (opts.expectedHostname) {
|
|
6040
|
-
const hostnameResult =
|
|
7287
|
+
const hostnameResult = spawnSync9("/bin/hostname", ["-s"], {
|
|
6041
7288
|
encoding: "utf8",
|
|
6042
7289
|
stdio: ["ignore", "pipe", "ignore"],
|
|
6043
7290
|
env: {
|
|
@@ -6066,7 +7313,7 @@ appCommand.command("install").description("Install a release or explicitly appro
|
|
|
6066
7313
|
process.exit(1);
|
|
6067
7314
|
}
|
|
6068
7315
|
const home = process.env.HOME || process.env.USERPROFILE || "";
|
|
6069
|
-
const canonicalAppPath = pathJoin(home, "Applications", "
|
|
7316
|
+
const canonicalAppPath = pathJoin(home, "Applications", "Hasna Recordings.app");
|
|
6070
7317
|
const legacyInstallPaths = findLegacyMacOSAppPaths(home, canonicalAppPath);
|
|
6071
7318
|
const installedAppPath = resolveInstalledAppPath(home, canonicalAppPath, legacyInstallPaths);
|
|
6072
7319
|
const updateClientPath = pathJoin(installedAppPath, "Contents", "Helpers", "recordings-update-client");
|
|
@@ -6077,7 +7324,7 @@ appCommand.command("install").description("Install a release or explicitly appro
|
|
|
6077
7324
|
}
|
|
6078
7325
|
const result2 = (() => {
|
|
6079
7326
|
try {
|
|
6080
|
-
return
|
|
7327
|
+
return spawnSync9(updateClientPath, [
|
|
6081
7328
|
"install",
|
|
6082
7329
|
"--artifact",
|
|
6083
7330
|
opts.artifact,
|
|
@@ -6176,7 +7423,7 @@ appCommand.command("install").description("Install a release or explicitly appro
|
|
|
6176
7423
|
if (opts.launch)
|
|
6177
7424
|
installerArgs.push("--launch");
|
|
6178
7425
|
const installerEnvironment = createInstallerEnvironment(process.env, bunExecutable);
|
|
6179
|
-
const result =
|
|
7426
|
+
const result = spawnSync9("/bin/bash", installerArgs, {
|
|
6180
7427
|
stdio: "inherit",
|
|
6181
7428
|
env: installerEnvironment
|
|
6182
7429
|
});
|
|
@@ -6186,7 +7433,7 @@ appCommand.command("install").description("Install a release or explicitly appro
|
|
|
6186
7433
|
}
|
|
6187
7434
|
process.exit(result.status ?? 1);
|
|
6188
7435
|
});
|
|
6189
|
-
appCommand.command("status").description("Show installed
|
|
7436
|
+
appCommand.command("status").description("Show installed Hasna Recordings.app status").option("--verbose", "Show package paths, code hash, and log path").action((opts) => {
|
|
6190
7437
|
const status = getMacOSAppStatus();
|
|
6191
7438
|
const trigger = probeTriggerDiagnostics({
|
|
6192
7439
|
accessibilityPermission: process.platform === "darwin" ? status.accessibility_permission : null,
|
|
@@ -6226,7 +7473,7 @@ appCommand.command("status").description("Show installed HasnaRecordings.app sta
|
|
|
6226
7473
|
console.log(chalk.dim("Use --verbose for paths/code hash/log, or --json for the full status object."));
|
|
6227
7474
|
}
|
|
6228
7475
|
});
|
|
6229
|
-
appCommand.command("permissions").description("Show macOS permission state for
|
|
7476
|
+
appCommand.command("permissions").description("Show macOS permission state for Hasna Recordings.app").action(() => {
|
|
6230
7477
|
const status = getMacOSAppStatus();
|
|
6231
7478
|
const permissions = {
|
|
6232
7479
|
platform: status.platform,
|
|
@@ -6262,21 +7509,21 @@ appCommand.command("permissions").description("Show macOS permission state for H
|
|
|
6262
7509
|
}
|
|
6263
7510
|
console.log(`Log: ${permissions.log_path}`);
|
|
6264
7511
|
});
|
|
6265
|
-
appCommand.command("reset-permissions").description("Reset macOS Microphone and Accessibility permissions for
|
|
7512
|
+
appCommand.command("reset-permissions").description("Reset macOS Microphone and Accessibility permissions for Hasna Recordings.app").action(() => {
|
|
6266
7513
|
if (process.platform !== "darwin") {
|
|
6267
7514
|
console.error(chalk.red("Permission reset is only available on macOS"));
|
|
6268
7515
|
process.exit(1);
|
|
6269
7516
|
}
|
|
6270
7517
|
resetMacOSPermissions();
|
|
6271
7518
|
});
|
|
6272
|
-
appCommand.command("request-permissions").description("Open
|
|
7519
|
+
appCommand.command("request-permissions").description("Open Hasna Recordings.app and trigger macOS Microphone and Accessibility permission prompts").option("--reset", "Reset existing Microphone and Accessibility decisions before requesting").action((opts) => {
|
|
6273
7520
|
if (process.platform !== "darwin") {
|
|
6274
7521
|
console.error(chalk.red("Permission prompts are only available on macOS"));
|
|
6275
7522
|
process.exit(1);
|
|
6276
7523
|
}
|
|
6277
7524
|
const status = getMacOSAppStatus();
|
|
6278
7525
|
if (!status.installed) {
|
|
6279
|
-
console.error(chalk.red("
|
|
7526
|
+
console.error(chalk.red("Hasna Recordings.app is not installed. Run: recordings app install"));
|
|
6280
7527
|
process.exit(1);
|
|
6281
7528
|
}
|
|
6282
7529
|
if (opts.reset) {
|
|
@@ -6288,14 +7535,14 @@ appCommand.command("request-permissions").description("Open HasnaRecordings.app
|
|
|
6288
7535
|
}
|
|
6289
7536
|
process.exit(result.exitCode);
|
|
6290
7537
|
});
|
|
6291
|
-
appCommand.command("log").description("Show the
|
|
7538
|
+
appCommand.command("log").description("Show the Hasna Recordings.app diagnostic log").option("-n, --lines <lines>", "Number of lines to print", String(DEFAULT_LOG_LINES)).action((opts) => {
|
|
6292
7539
|
const status = getMacOSAppStatus();
|
|
6293
7540
|
if (!existsSync8(status.log_path)) {
|
|
6294
7541
|
console.log("");
|
|
6295
7542
|
return;
|
|
6296
7543
|
}
|
|
6297
7544
|
const lines = Math.max(1, parseInt(opts.lines, 10) || DEFAULT_LOG_LINES);
|
|
6298
|
-
const result =
|
|
7545
|
+
const result = spawnSync9("tail", ["-n", String(lines), status.log_path], {
|
|
6299
7546
|
encoding: "utf8"
|
|
6300
7547
|
});
|
|
6301
7548
|
if (result.error) {
|
|
@@ -6314,17 +7561,17 @@ appCommand.command("snapshot [output]").description("Write the current main desk
|
|
|
6314
7561
|
console.log(chalk.green(`Desktop snapshot written: ${path}`));
|
|
6315
7562
|
}
|
|
6316
7563
|
});
|
|
6317
|
-
appCommand.command("open").description("Open the installed
|
|
7564
|
+
appCommand.command("open").description("Open the installed Hasna Recordings.app").action(() => {
|
|
6318
7565
|
const status = getMacOSAppStatus();
|
|
6319
7566
|
if (process.platform !== "darwin") {
|
|
6320
|
-
console.error(chalk.red("
|
|
7567
|
+
console.error(chalk.red("Hasna Recordings.app can only be opened on macOS"));
|
|
6321
7568
|
process.exit(1);
|
|
6322
7569
|
}
|
|
6323
7570
|
if (!status.installed) {
|
|
6324
|
-
console.error(chalk.red("
|
|
7571
|
+
console.error(chalk.red("Hasna Recordings.app is not installed. Run: recordings app install"));
|
|
6325
7572
|
process.exit(1);
|
|
6326
7573
|
}
|
|
6327
|
-
const result =
|
|
7574
|
+
const result = spawnSync9("open", [status.installed_app_path], { stdio: "inherit" });
|
|
6328
7575
|
if (result.error) {
|
|
6329
7576
|
console.error(chalk.red(result.error.message));
|
|
6330
7577
|
process.exit(1);
|
|
@@ -6410,8 +7657,9 @@ program.command("check").description("Check system dependencies (sox, API keys)
|
|
|
6410
7657
|
persistence_probe: persistence,
|
|
6411
7658
|
trigger
|
|
6412
7659
|
}, null, 2));
|
|
6413
|
-
if (probeFailed || triggerFailed)
|
|
7660
|
+
if (probeFailed || triggerFailed || activeStoreFailClosed(activeStore)) {
|
|
6414
7661
|
process.exitCode = 1;
|
|
7662
|
+
}
|
|
6415
7663
|
return;
|
|
6416
7664
|
}
|
|
6417
7665
|
if (deps.available) {
|
|
@@ -6422,14 +7670,18 @@ program.command("check").description("Check system dependencies (sox, API keys)
|
|
|
6422
7670
|
if (config.openai_api_key) {
|
|
6423
7671
|
console.log(chalk.green(`\u2713 OpenAI API key present`) + chalk.dim(opts.probe ? "" : " (presence only \u2014 run 'recordings check --probe' to verify it is accepted)"));
|
|
6424
7672
|
} else {
|
|
6425
|
-
console.log(chalk.red(`\u2717 OpenAI API key not found. Set OPENAI_API_KEY
|
|
7673
|
+
console.log(chalk.red(`\u2717 OpenAI API key not found. Set OPENAI_API_KEY (e.g. ` + `'secrets exec <key> --as OPENAI_API_KEY -- recordings \u2026')`));
|
|
6426
7674
|
}
|
|
6427
7675
|
if (enhKey) {
|
|
6428
7676
|
console.log(chalk.green(`\u2713 Enhancement API key present (model: ${transcriberModel})`) + chalk.dim(opts.probe ? "" : " (presence only \u2014 'recordings check --probe' verifies it is accepted)"));
|
|
6429
7677
|
} else {
|
|
6430
7678
|
console.log(chalk.yellow(`\u26A0 Enhancement API key not configured \u2014 enhancement disabled`));
|
|
6431
7679
|
}
|
|
6432
|
-
console.log(
|
|
7680
|
+
console.log(describeActiveStoreLine(activeStore, {
|
|
7681
|
+
pass: (text) => chalk.green(text),
|
|
7682
|
+
warn: (text) => chalk.yellow(text),
|
|
7683
|
+
fail: (text) => chalk.red(text)
|
|
7684
|
+
}));
|
|
6433
7685
|
if (activeStore.divergent) {
|
|
6434
7686
|
console.log(chalk.yellow("\u26A0") + ` Two datasets present: ${activeStore.local_db_recordings} recordings sit in ` + `${activeStore.local_db_path}, which is NOT the live store.`);
|
|
6435
7687
|
}
|
|
@@ -6453,7 +7705,7 @@ program.command("check").description("Check system dependencies (sox, API keys)
|
|
|
6453
7705
|
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."));
|
|
6454
7706
|
}
|
|
6455
7707
|
if (!micOk && !micUnreadable) {
|
|
6456
|
-
console.log(chalk.dim("
|
|
7708
|
+
console.log(chalk.dim(" Hasna Recordings.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:"));
|
|
6457
7709
|
const instruction = microphoneGrantInstruction({
|
|
6458
7710
|
installedAppPath: macStatus.installed_app_path,
|
|
6459
7711
|
otherAppPaths: macStatus.legacy_install_paths,
|
|
@@ -6486,7 +7738,7 @@ program.command("check").description("Check system dependencies (sox, API keys)
|
|
|
6486
7738
|
});
|
|
6487
7739
|
console.log(persistenceMarker + ` Persistence round-trip: ${persistence.message}`);
|
|
6488
7740
|
}
|
|
6489
|
-
if (probeFailed || triggerFailed)
|
|
7741
|
+
if (probeFailed || triggerFailed || activeStoreFailClosed(activeStore))
|
|
6490
7742
|
process.exitCode = 1;
|
|
6491
7743
|
});
|
|
6492
7744
|
function reportTriggerDiagnosis(trigger) {
|
|
@@ -6612,7 +7864,7 @@ Bye.`));
|
|
|
6612
7864
|
}
|
|
6613
7865
|
});
|
|
6614
7866
|
});
|
|
6615
|
-
program.command("shortcut").description("Show or change the global recording trigger (macOS). Exits non-zero when a change was " + "written while
|
|
7867
|
+
program.command("shortcut").description("Show or change the global recording trigger (macOS). Exits non-zero when a change was " + "written while Hasna Recordings.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) => {
|
|
6616
7868
|
const { writeFileSync: writeFileSync4, mkdirSync: mkdirSync5, chmodSync: chmodSync4, existsSync: existsSync9 } = __require("fs");
|
|
6617
7869
|
const { join: pathJoin2 } = __require("path");
|
|
6618
7870
|
const { homedir: getHome } = __require("os");
|
|
@@ -6726,10 +7978,10 @@ program.command("shortcut").description("Show or change the global recording tri
|
|
|
6726
7978
|
function reportPickup() {
|
|
6727
7979
|
const pickup = describeTriggerPickup(runningBundles());
|
|
6728
7980
|
if (pickup.armed) {
|
|
6729
|
-
console.log(chalk.dim("
|
|
7981
|
+
console.log(chalk.dim(" Hasna Recordings.app is not running; it will register this on next launch."));
|
|
6730
7982
|
return;
|
|
6731
7983
|
}
|
|
6732
|
-
console.log(chalk.yellow("
|
|
7984
|
+
console.log(chalk.yellow(" Hasna Recordings.app is running and still holds the previous trigger.") + `
|
|
6733
7985
|
Quit and reopen it to arm this one:`);
|
|
6734
7986
|
for (const path of pickup.runningBundlePaths)
|
|
6735
7987
|
console.log(chalk.dim(` ${path}`));
|
|
@@ -6788,7 +8040,7 @@ program.command("shortcut").description("Show or change the global recording tri
|
|
|
6788
8040
|
` + ` ${fnGrant.settingsPath} > enable Recordings`);
|
|
6789
8041
|
const target = grantTargetPaths();
|
|
6790
8042
|
if (target.paths.length === 0) {
|
|
6791
|
-
console.log(chalk.dim(" (no installed
|
|
8043
|
+
console.log(chalk.dim(" (no installed Hasna Recordings.app found to grant it to)"));
|
|
6792
8044
|
}
|
|
6793
8045
|
for (const path of target.paths) {
|
|
6794
8046
|
console.log(chalk.dim(` grant it to: ${path}${target.running ? "" : " (not running \u2014 installed copy)"}`));
|
|
@@ -6815,7 +8067,7 @@ program.command("shortcut").description("Show or change the global recording tri
|
|
|
6815
8067
|
# Toggle recording on/off. Run this from a global hotkey.
|
|
6816
8068
|
# Each press toggles: start recording -> stop + transcribe + copy to clipboard
|
|
6817
8069
|
#
|
|
6818
|
-
# This is the app-less path. If
|
|
8070
|
+
# This is the app-less path. If Hasna Recordings.app is running, prefer its own hotkey
|
|
6819
8071
|
# ("recordings shortcut --set ..."): the app streams transcription and pastes into
|
|
6820
8072
|
# the focused field, which this script cannot do.
|
|
6821
8073
|
set -e
|
|
@@ -7012,7 +8264,7 @@ async function readSaveTextInput(text, opts) {
|
|
|
7012
8264
|
}
|
|
7013
8265
|
let rawText;
|
|
7014
8266
|
if (opts.textFile !== undefined) {
|
|
7015
|
-
rawText =
|
|
8267
|
+
rawText = readFileSync6(opts.textFile, "utf8");
|
|
7016
8268
|
} else if (opts.stdin) {
|
|
7017
8269
|
rawText = await Bun.stdin.text();
|
|
7018
8270
|
} else {
|
|
@@ -7106,7 +8358,7 @@ async function printRecordingDetail(id) {
|
|
|
7106
8358
|
return;
|
|
7107
8359
|
}
|
|
7108
8360
|
if (parentOpts.json) {
|
|
7109
|
-
|
|
8361
|
+
await printJSON(recording);
|
|
7110
8362
|
return;
|
|
7111
8363
|
}
|
|
7112
8364
|
console.log(formatRecordingDetail(recording));
|
|
@@ -7226,7 +8478,7 @@ function relativeHint(value) {
|
|
|
7226
8478
|
return `${Math.floor(hours / 24)}d ago`;
|
|
7227
8479
|
}
|
|
7228
8480
|
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) => {
|
|
7229
|
-
const { readFileSync:
|
|
8481
|
+
const { readFileSync: readFileSync7, writeFileSync: writeFileSync4, existsSync: fileExists } = __require("fs");
|
|
7230
8482
|
const { join: pathJoin2 } = __require("path");
|
|
7231
8483
|
const { homedir: getHome } = __require("os");
|
|
7232
8484
|
const { execSync } = __require("child_process");
|
|
@@ -7259,7 +8511,7 @@ program.command("mcp").description("Install recordings MCP server into Claude Co
|
|
|
7259
8511
|
if (target === "codex") {
|
|
7260
8512
|
const configPath = pathJoin2(home, ".codex", "config.toml");
|
|
7261
8513
|
if (fileExists(configPath)) {
|
|
7262
|
-
const content =
|
|
8514
|
+
const content = readFileSync7(configPath, "utf-8");
|
|
7263
8515
|
if (opts.uninstall) {
|
|
7264
8516
|
const { content: next, removed } = removeCodexServerBlock(content, "recordings");
|
|
7265
8517
|
writeFileSync4(configPath, next, "utf-8");
|
|
@@ -7277,7 +8529,7 @@ program.command("mcp").description("Install recordings MCP server into Claude Co
|
|
|
7277
8529
|
const configPath = pathJoin2(home, ".gemini", "settings.json");
|
|
7278
8530
|
let config = {};
|
|
7279
8531
|
if (fileExists(configPath)) {
|
|
7280
|
-
config = JSON.parse(
|
|
8532
|
+
config = JSON.parse(readFileSync7(configPath, "utf-8"));
|
|
7281
8533
|
}
|
|
7282
8534
|
const servers = config["mcpServers"] || {};
|
|
7283
8535
|
if (opts.uninstall) {
|
|
@@ -7316,7 +8568,7 @@ program.command("feedback <message>").description("Send feedback").option("--ema
|
|
|
7316
8568
|
function getMacOSAppStatus() {
|
|
7317
8569
|
const packageRoot = findPackageRoot();
|
|
7318
8570
|
const home = process.env.HOME || process.env.USERPROFILE || "";
|
|
7319
|
-
const canonicalAppPath = pathJoin(home, "Applications", "
|
|
8571
|
+
const canonicalAppPath = pathJoin(home, "Applications", "Hasna Recordings.app");
|
|
7320
8572
|
const legacyInstallPaths = findLegacyMacOSAppPaths(home, canonicalAppPath);
|
|
7321
8573
|
const installedAppPath = resolveInstalledAppPath(home, canonicalAppPath, legacyInstallPaths);
|
|
7322
8574
|
const installedAppPathForGrants = existsSync8(installedAppPath) ? installedAppPath : null;
|
|
@@ -7364,7 +8616,7 @@ function buildPermissionWarnings(status) {
|
|
|
7364
8616
|
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");
|
|
7365
8617
|
}
|
|
7366
8618
|
if (status.ambiguous_installations) {
|
|
7367
|
-
warnings.push("more than one
|
|
8619
|
+
warnings.push("more than one Hasna Recordings.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(", ")}`);
|
|
7368
8620
|
}
|
|
7369
8621
|
for (const [service, durability] of [
|
|
7370
8622
|
["Microphone", status.microphone_grant_durability],
|
|
@@ -7387,8 +8639,11 @@ function buildPermissionWarnings(status) {
|
|
|
7387
8639
|
function resolveInstalledAppPath(home, canonicalPath, legacyPaths) {
|
|
7388
8640
|
const preference = [
|
|
7389
8641
|
canonicalPath,
|
|
8642
|
+
pathJoin("/", "Applications", "Hasna Recordings.app"),
|
|
8643
|
+
pathJoin(home, "Applications", "HasnaRecordings.app"),
|
|
7390
8644
|
pathJoin("/", "Applications", "HasnaRecordings.app"),
|
|
7391
8645
|
pathJoin("/", "Applications", "Recordings.app"),
|
|
8646
|
+
pathJoin(home, ".hasna", "recordings", "Hasna Recordings.app"),
|
|
7392
8647
|
pathJoin(home, ".hasna", "recordings", "HasnaRecordings.app"),
|
|
7393
8648
|
pathJoin(home, ".hasna", "recordings", "Recordings.app")
|
|
7394
8649
|
];
|
|
@@ -7400,15 +8655,18 @@ function resolveInstalledAppPath(home, canonicalPath, legacyPaths) {
|
|
|
7400
8655
|
}
|
|
7401
8656
|
function findLegacyMacOSAppPaths(home, canonicalPath) {
|
|
7402
8657
|
const candidates = [
|
|
7403
|
-
pathJoin(home, "
|
|
8658
|
+
pathJoin(home, "Applications", "HasnaRecordings.app"),
|
|
7404
8659
|
pathJoin(home, ".hasna", "recordings", "HasnaRecordings.app"),
|
|
8660
|
+
pathJoin("/", "Applications", "HasnaRecordings.app"),
|
|
8661
|
+
pathJoin(home, ".hasna", "recordings", "Recordings.app"),
|
|
8662
|
+
pathJoin(home, ".hasna", "recordings", "Hasna Recordings.app"),
|
|
7405
8663
|
pathJoin("/", "Applications", "Recordings.app"),
|
|
7406
|
-
pathJoin("/", "Applications", "
|
|
8664
|
+
pathJoin("/", "Applications", "Hasna Recordings.app")
|
|
7407
8665
|
];
|
|
7408
8666
|
const userApplications = pathJoin(home, "Applications");
|
|
7409
8667
|
if (existsSync8(userApplications)) {
|
|
7410
8668
|
for (const entry of readdirSync2(userApplications, { withFileTypes: true })) {
|
|
7411
|
-
if (entry.isDirectory() && (entry.name.startsWith("Recordings.app.") || entry.name.startsWith("HasnaRecordings.app."))) {
|
|
8669
|
+
if (entry.isDirectory() && (entry.name.startsWith("Recordings.app.") || entry.name.startsWith("Hasna Recordings.app.") || entry.name.startsWith("HasnaRecordings.app."))) {
|
|
7412
8670
|
candidates.push(pathJoin(userApplications, entry.name));
|
|
7413
8671
|
}
|
|
7414
8672
|
}
|
|
@@ -7418,7 +8676,7 @@ function findLegacyMacOSAppPaths(home, canonicalPath) {
|
|
|
7418
8676
|
function resetMacOSPermissions() {
|
|
7419
8677
|
const services = ["Microphone", "Accessibility"];
|
|
7420
8678
|
for (const service of services) {
|
|
7421
|
-
const result =
|
|
8679
|
+
const result = spawnSync9("/usr/bin/tccutil", ["reset", service, RECORDINGS_BUNDLE_IDENTIFIER], { stdio: "inherit" });
|
|
7422
8680
|
if (result.error) {
|
|
7423
8681
|
console.error(chalk.red(result.error.message));
|
|
7424
8682
|
process.exit(1);
|
|
@@ -7436,7 +8694,7 @@ function getCodeSigningInfo(appPath) {
|
|
|
7436
8694
|
authorities: []
|
|
7437
8695
|
};
|
|
7438
8696
|
}
|
|
7439
|
-
const result =
|
|
8697
|
+
const result = spawnSync9("/usr/bin/codesign", ["-d", "-r-", "--verbose=4", appPath], {
|
|
7440
8698
|
encoding: "utf8",
|
|
7441
8699
|
stdio: ["ignore", "pipe", "pipe"]
|
|
7442
8700
|
});
|
|
@@ -7465,7 +8723,7 @@ function findPackageRoot() {
|
|
|
7465
8723
|
const packagePath = pathJoin(current, "package.json");
|
|
7466
8724
|
if (existsSync8(packagePath)) {
|
|
7467
8725
|
try {
|
|
7468
|
-
const pkg = JSON.parse(
|
|
8726
|
+
const pkg = JSON.parse(readFileSync6(packagePath, "utf8"));
|
|
7469
8727
|
if (pkg.name === "@hasna/recordings") {
|
|
7470
8728
|
return current;
|
|
7471
8729
|
}
|
|
@@ -7481,6 +8739,17 @@ function findPackageRoot() {
|
|
|
7481
8739
|
function getMacOSInstallerPath(packageRoot = findPackageRoot()) {
|
|
7482
8740
|
return pathJoin(packageRoot, "scripts", "install_macos_app.sh");
|
|
7483
8741
|
}
|
|
8742
|
+
async function printJSON(value) {
|
|
8743
|
+
await new Promise((resolve4, reject) => {
|
|
8744
|
+
process.stdout.write(`${JSON.stringify(value, null, 2)}
|
|
8745
|
+
`, (error) => {
|
|
8746
|
+
if (error)
|
|
8747
|
+
reject(error);
|
|
8748
|
+
else
|
|
8749
|
+
resolve4();
|
|
8750
|
+
});
|
|
8751
|
+
});
|
|
8752
|
+
}
|
|
7484
8753
|
program.parseAsync().catch((error) => {
|
|
7485
8754
|
const msg = error instanceof Error ? error.message : String(error);
|
|
7486
8755
|
console.error(`ERROR: ${msg}`);
|