@hasna/hooks 0.7.9 → 0.8.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 +32 -6
- package/bin/index.js +1573 -695
- package/bin/serve.js +713 -335
- package/dist/config.d.ts +25 -19
- package/dist/db/index.d.ts +3 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.js +1162 -473
- package/dist/lib/app-home.d.ts +74 -0
- package/dist/lib/installer.d.ts +0 -9
- package/dist/lib/local-opt-in.d.ts +80 -0
- package/dist/lib/profiles.d.ts +14 -1
- package/dist/lib/resolver-types.d.ts +45 -0
- package/dist/lib/sync.d.ts +13 -8
- package/dist/lib/transport.d.ts +87 -0
- package/dist/openapi.d.ts +1 -1
- package/dist/serve.d.ts +7 -0
- package/dist/storage.js +141 -27
- package/hooks/hook-affected-tests/package.json +1 -1
- package/hooks/hook-agent-rules-version-check/package.json +1 -1
- package/hooks/hook-agentmessages/package.json +1 -1
- package/hooks/hook-announce-start/package.json +1 -1
- package/hooks/hook-announce-stop/package.json +1 -1
- package/hooks/hook-autoformat/package.json +1 -1
- package/hooks/hook-branchprotect/package.json +1 -1
- package/hooks/hook-checkbugs/package.json +1 -1
- package/hooks/hook-checkdocs/package.json +1 -1
- package/hooks/hook-checkfiles/package.json +1 -1
- package/hooks/hook-checklint/package.json +1 -1
- package/hooks/hook-checkpoint/package.json +1 -1
- package/hooks/hook-checksecurity/package.json +1 -1
- package/hooks/hook-checktasks/package.json +1 -1
- package/hooks/hook-checktests/package.json +1 -1
- package/hooks/hook-conflict-detect/package.json +1 -1
- package/hooks/hook-contextrefresh/package.json +1 -1
- package/hooks/hook-desktopnotify/package.json +1 -1
- package/hooks/hook-dm-inject/package.json +1 -1
- package/hooks/hook-envsetup/package.json +1 -1
- package/hooks/hook-failure-to-task/package.json +1 -1
- package/hooks/hook-filelock/package.json +1 -1
- package/hooks/hook-fleet-blockers-gate/package.json +1 -1
- package/hooks/hook-fleet-catchup/package.json +1 -1
- package/hooks/hook-gitguard/package.json +1 -1
- package/hooks/hook-packageage/package.json +1 -1
- package/hooks/hook-permissionguard/package.json +1 -1
- package/hooks/hook-phonenotify/package.json +1 -1
- package/hooks/hook-precompact/package.json +1 -1
- package/hooks/hook-protectfiles/package.json +1 -1
- package/hooks/hook-stylescheck/package.json +1 -1
- package/hooks/hook-typecheck-gate/package.json +1 -1
- package/package.json +6 -9
- package/scripts/artifact-scan.ts +49 -0
- package/scripts/ensure-profiles-dir.mjs +99 -0
- package/scripts/validate-package.ts +280 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* App-home resolution for @hasna/hooks — routes the local data root through
|
|
3
|
+
* the @hasna/paths resolver (XDG / macOS home layout) with gated legacy
|
|
4
|
+
* adoption.
|
|
5
|
+
*
|
|
6
|
+
* The XDG home migration (hotfixes plan 0f49f56a, task P3.3) moves the store
|
|
7
|
+
* from `~/.hasna/hooks` toward `~/.local/share/hasna/hooks` on Linux and
|
|
8
|
+
* `~/Library/Application Support/Hasna/hooks` on macOS. Nothing moves on disk
|
|
9
|
+
* in this phase — the resolver root is adopted only when the operator has
|
|
10
|
+
* deliberately opted in (`HASNA_DATA_HOME`) or the store has already been
|
|
11
|
+
* physically migrated there, so a live store at the legacy home never becomes
|
|
12
|
+
* invisible on upgrade.
|
|
13
|
+
*/
|
|
14
|
+
export type PathKind = "config" | "data" | "state" | "cache";
|
|
15
|
+
export interface PathsResolverOptions {
|
|
16
|
+
app: string;
|
|
17
|
+
internal?: boolean;
|
|
18
|
+
platform?: string;
|
|
19
|
+
home?: string;
|
|
20
|
+
env?: Record<string, string | undefined>;
|
|
21
|
+
}
|
|
22
|
+
export declare function dataDir(options: PathsResolverOptions): string;
|
|
23
|
+
/** Resolve the user's home directory: $HOME, then $USERPROFILE, then the OS user database. */
|
|
24
|
+
export declare function getHomeDir(env?: NodeJS.ProcessEnv): string;
|
|
25
|
+
/**
|
|
26
|
+
* The @hasna/paths-resolved (XDG / macOS layout) data root for hooks: the
|
|
27
|
+
* forward-looking home the XDG migration moves the store toward. The home
|
|
28
|
+
* override mirrors the pre-existing $HOME-first resolution so the resolver
|
|
29
|
+
* follows the same home the legacy path does.
|
|
30
|
+
*/
|
|
31
|
+
export declare function getResolverDataRoot(env?: NodeJS.ProcessEnv): string;
|
|
32
|
+
/** The legacy (pre-XDG) data root: ~/.hasna/hooks */
|
|
33
|
+
export declare function getLegacyDataRoot(env?: NodeJS.ProcessEnv): string;
|
|
34
|
+
/**
|
|
35
|
+
* Whether the resolver (XDG) data root should be adopted as the effective data
|
|
36
|
+
* root. The resolver root is adopted only when the operator has set
|
|
37
|
+
* `HASNA_DATA_HOME` (the data-kind override — a deliberate opt-in to the XDG
|
|
38
|
+
* layout) or the store has already been physically migrated there (`hooks.db`
|
|
39
|
+
* exists). A machine that only redirects another kind (e.g. cache to tmpfs)
|
|
40
|
+
* must NOT have its data home moved, and a live store at the legacy home must
|
|
41
|
+
* never become invisible on upgrade.
|
|
42
|
+
*/
|
|
43
|
+
export declare function adoptResolverDataRoot(resolved: string, env?: NodeJS.ProcessEnv): boolean;
|
|
44
|
+
/**
|
|
45
|
+
* The granular data-dir override root, when set: `HASNA_HOOKS_DATA_DIR`, then
|
|
46
|
+
* `HOOKS_DATA_DIR`. This preserves the pre-existing exact-data-dir override
|
|
47
|
+
* behavior verbatim (first-nonblank; a set-but-empty value falls through).
|
|
48
|
+
*/
|
|
49
|
+
export declare function getExplicitDataDir(env?: NodeJS.ProcessEnv): string | undefined;
|
|
50
|
+
/**
|
|
51
|
+
* The exact-app override root, when set: `HASNA_HOOKS_HOME`, then `HOOKS_HOME`.
|
|
52
|
+
* First-nonblank selection: a set-but-whitespace override must not suppress a
|
|
53
|
+
* valid fallback (the same `?.trim() ||` semantics the emails lane settled as
|
|
54
|
+
* release-review P1).
|
|
55
|
+
*/
|
|
56
|
+
export declare function getExactDataRoot(env?: NodeJS.ProcessEnv): string | undefined;
|
|
57
|
+
/**
|
|
58
|
+
* The effective data root for hooks. Precedence: the granular data-dir
|
|
59
|
+
* override (`HASNA_HOOKS_DATA_DIR`/`HOOKS_DATA_DIR`) wins; then the exact-app
|
|
60
|
+
* override (`HASNA_HOOKS_HOME`/`HOOKS_HOME`); then the resolver (XDG) data
|
|
61
|
+
* root once adopted; then the legacy `~/.hasna/hooks` default — a live store
|
|
62
|
+
* never becomes invisible on upgrade. Store/lock/config paths are layered on
|
|
63
|
+
* top of this by their own overrides, so an explicit path always wins
|
|
64
|
+
* regardless.
|
|
65
|
+
*/
|
|
66
|
+
export declare function getEffectiveDataRoot(env?: NodeJS.ProcessEnv): string;
|
|
67
|
+
/**
|
|
68
|
+
* The SQLite store path surfaced by help/status surfaces (e.g. `hooks log`,
|
|
69
|
+
* registry hook descriptions). The explicit `HASNA_HOOKS_DB_PATH` /
|
|
70
|
+
* `HOOKS_DB_PATH` override wins; otherwise `hooks.db` under the effective data
|
|
71
|
+
* root. A status surface must never hardcode the legacy literal — the store can
|
|
72
|
+
* live at the resolver home once adopted.
|
|
73
|
+
*/
|
|
74
|
+
export declare function getReportedDbPath(env?: NodeJS.ProcessEnv): string;
|
package/dist/lib/installer.d.ts
CHANGED
|
@@ -81,14 +81,5 @@ export declare function removeCodewithHookEntry(configText: string, name: string
|
|
|
81
81
|
text: string;
|
|
82
82
|
removed: boolean;
|
|
83
83
|
};
|
|
84
|
-
/**
|
|
85
|
-
* Full uninstall — the settings registration, the store directory (custom
|
|
86
|
-
* hooks), the lock pin and the DB record are all removed. Bundled hooks keep
|
|
87
|
-
* their package files (they belong to the package, not the store).
|
|
88
|
-
*
|
|
89
|
-
* Resolves custom and registry-synced hooks, which live in the custom store
|
|
90
|
-
* dir, as well as bundled ones (QA-1 BUG-A / QA-4: remove was bundled-only
|
|
91
|
-
* and never cleaned the store/lock/DB).
|
|
92
|
-
*/
|
|
93
84
|
export declare function uninstallHook(name: string, scope?: Scope, target?: Target): UninstallResult;
|
|
94
85
|
export {};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { HooksCredentialOptions, HooksLocalOptInEnv } from "./resolver-types.js";
|
|
2
|
+
/** The deliberate unhosted opt-in, canonical name first. */
|
|
3
|
+
export declare const HOOKS_LOCAL_OPT_IN_ENV_KEYS: readonly ["HASNA_HOOKS_LOCAL", "HOOKS_LOCAL"];
|
|
4
|
+
/** True when the operator deliberately asked for the unhosted local store. */
|
|
5
|
+
export declare function isHooksLocalOptIn(env?: HooksLocalOptInEnv): boolean;
|
|
6
|
+
/** Every env name that can configure a hooks authority or credential, resolver-derived. */
|
|
7
|
+
export declare function hooksAuthorityEnvKeys(): string[];
|
|
8
|
+
/**
|
|
9
|
+
* Does the ENVIRONMENT itself configure a hooks authority or credential?
|
|
10
|
+
*
|
|
11
|
+
* Deliberately narrower than "does a credential resolve": answering it must not
|
|
12
|
+
* touch the Keychain or the filesystem, because doing so would defeat the
|
|
13
|
+
* isolation the opt-in short-circuit exists to provide. It reads the env
|
|
14
|
+
* dictionary and nothing else.
|
|
15
|
+
*
|
|
16
|
+
* A DECLARED-BUT-BLANK variable counts as absent HERE — a blank has always been
|
|
17
|
+
* this package's spelling for "not configured", and helpers in the wild blank
|
|
18
|
+
* rather than delete. It is NOT absent once we do go hosted: the resolver
|
|
19
|
+
* refuses a blank loudly rather than falling through to another identity, which
|
|
20
|
+
* is the behaviour that matters at that point.
|
|
21
|
+
*/
|
|
22
|
+
export declare function hasHooksEnvAuthorityIntent(env?: HooksLocalOptInEnv): boolean;
|
|
23
|
+
/** True when this environment should be served by the on-box bundled store. */
|
|
24
|
+
export declare function selectsHooksLocalStore(env?: HooksLocalOptInEnv): boolean;
|
|
25
|
+
/**
|
|
26
|
+
* The environment as the resolver should see it: every authority/credential
|
|
27
|
+
* variable that is DECLARED BUT BLANK removed.
|
|
28
|
+
*
|
|
29
|
+
* A blank has always been this package's spelling for "not configured".
|
|
30
|
+
* @hasna/contracts takes the opposite and, for its purposes, correct view: a
|
|
31
|
+
* declared-but-blank credential is a misconfiguration it refuses loudly rather
|
|
32
|
+
* than resolving around, because a blank that fell through would authenticate
|
|
33
|
+
* as a different principal than the operator named.
|
|
34
|
+
*
|
|
35
|
+
* Both are right at their own layer, and the mismatch is not hypothetical: an
|
|
36
|
+
* environment carrying a real `HASNA_HOOKS_API_KEY` alongside a blank legacy
|
|
37
|
+
* alias — the exact shape a scrubbed-then-overridden fixture produces — is a
|
|
38
|
+
* complete, unambiguous configuration that would otherwise be refused for the
|
|
39
|
+
* alias nobody set. Normalising here keeps "blank means unset" true at the
|
|
40
|
+
* hooks seam while leaving the resolver's stricter rule intact for everything
|
|
41
|
+
* it does receive: a value that is present is still policed, and two aliases
|
|
42
|
+
* that actually disagree still refuse.
|
|
43
|
+
*/
|
|
44
|
+
export declare function hooksResolverEnv<T extends HooksLocalOptInEnv>(env: T): T;
|
|
45
|
+
/** The env object and credential options a hooks surface hands @hasna/contracts. */
|
|
46
|
+
export interface HooksResolverInputs<T extends HooksLocalOptInEnv> {
|
|
47
|
+
/** The environment with every declared-but-blank authority variable removed. */
|
|
48
|
+
env: T;
|
|
49
|
+
/** The chain options, with the Keychain tier's ambient gate already decided. */
|
|
50
|
+
credentials: HooksCredentialOptions;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Build the resolver's inputs: the normalised environment AND the credential
|
|
54
|
+
* options that keep the machine's Keychain tier reachable across it.
|
|
55
|
+
*
|
|
56
|
+
* WHY THIS IS NOT JUST {@link hooksResolverEnv}. Blanking a variable and
|
|
57
|
+
* deleting it are not the same operation to @hasna/contracts, because dropping
|
|
58
|
+
* a key forces us to hand the resolver a COPY, and the resolver gates its
|
|
59
|
+
* ambient tiers on OBJECT IDENTITY (`env === process.env`, or the registry
|
|
60
|
+
* symbol its own snapshot carries). A copy is, by that test, a caller-built
|
|
61
|
+
* world — the hermetic seam — so the Keychain is outside it and tier 3 turns
|
|
62
|
+
* itself off. Silently: there is no error, no warning and no diagnostic.
|
|
63
|
+
*
|
|
64
|
+
* The consequence is the one failure this whole ruling exists to prevent. On a
|
|
65
|
+
* station whose Keychain holds `hasna.credentials.hooks.api-key`, ONE
|
|
66
|
+
* declared-but-blank authority variable dropped the run from the Keychain
|
|
67
|
+
* identity to whatever came next: to `~/.hasna/hooks/config/credentials`, a
|
|
68
|
+
* DIFFERENT principal, with no notice; or, with nothing on disk, to a bare
|
|
69
|
+
* REMOTE_API_CONFIG_MISSING on a station that is in fact configured. A
|
|
70
|
+
* deliberate tier must never fall through to another identity, so the gate is
|
|
71
|
+
* decided HERE, on the original env, and carried across the copy as the
|
|
72
|
+
* documented `keychain.enabled` control rather than being left to an identity
|
|
73
|
+
* test the copy cannot pass.
|
|
74
|
+
*
|
|
75
|
+
* An explicit `enabled` from the caller still wins, and an injected `run`
|
|
76
|
+
* (which @hasna/contracts already treats as "enabled") is left alone, so the
|
|
77
|
+
* hermetic seam tests rely on is untouched. When there is no blank to remove
|
|
78
|
+
* the inputs pass through by identity, exactly as before.
|
|
79
|
+
*/
|
|
80
|
+
export declare function hooksResolverInputs<T extends HooksLocalOptInEnv>(env: T, credentials?: HooksCredentialOptions): HooksResolverInputs<T>;
|
package/dist/lib/profiles.d.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Agent profile management — identity system for hooks
|
|
3
3
|
*
|
|
4
|
-
* Each agent instance gets a unique 8-char UUID stored at
|
|
4
|
+
* Each agent instance gets a unique 8-char UUID stored at
|
|
5
|
+
* <effective-data-root>/profiles/<id>.json, where the effective data root is
|
|
6
|
+
* resolved through @hasna/paths (legacy ~/.hasna/hooks until adopted, then
|
|
7
|
+
* the XDG data home).
|
|
5
8
|
* Profiles are injected into HookInput when hooks are run with --profile <id>,
|
|
6
9
|
* allowing hooks to identify which agent is calling them.
|
|
7
10
|
*/
|
|
@@ -17,6 +20,16 @@ export interface CreateProfileInput {
|
|
|
17
20
|
agent_type: "claude" | "gemini" | "custom";
|
|
18
21
|
name?: string;
|
|
19
22
|
}
|
|
23
|
+
/**
|
|
24
|
+
* The profiles directory under the effective data root.
|
|
25
|
+
*
|
|
26
|
+
* Resolved on every call, never snapshotted at module load: the effective
|
|
27
|
+
* data root is env-driven (HASNA_HOOKS_DATA_DIR / HASNA_HOOKS_HOME / the XDG
|
|
28
|
+
* adoption in app-home), and bun test runs test files in one process with a
|
|
29
|
+
* SHARED process.env — a module-load snapshot froze the dir to whatever the
|
|
30
|
+
* env held at import time, so a concurrent file pinning a data-root override
|
|
31
|
+
* silently moved every profile operation of this module with it.
|
|
32
|
+
*/
|
|
20
33
|
export declare function getProfilesDir(): string;
|
|
21
34
|
export declare function createProfile(input: CreateProfileInput): AgentProfile;
|
|
22
35
|
export declare function getProfile(id: string): AgentProfile | null;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The @hasna/contracts client types that @hasna/hooks PUBLISHES or hands back
|
|
3
|
+
* across its public seam, spelled locally.
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS FILE EXISTS. `@hasna/contracts` is a BUILD-TIME dependency of this
|
|
6
|
+
* package (pinned 1.0.2, inlined by `bun build --target bun`), so the emitted
|
|
7
|
+
* `dist/**/*.d.ts` must never `import ... from "@hasna/contracts/client"` —
|
|
8
|
+
* a published declaration that references a package consumers do not have is
|
|
9
|
+
* a broken install (hasna/apps#1782). Every public signature therefore uses
|
|
10
|
+
* these local spellings, which are structurally identical to the resolver's
|
|
11
|
+
* own types (`CredentialChainOptions` / `KeychainCommandResult`). The
|
|
12
|
+
* `credential chain shape` test in `transport.test.ts` pins the local
|
|
13
|
+
* spellings against the real @hasna/contracts types so they cannot drift.
|
|
14
|
+
*/
|
|
15
|
+
export type HooksLocalOptInEnv = Record<string, string | undefined>;
|
|
16
|
+
/** What an injected `security` runner returns: an exit status plus output. */
|
|
17
|
+
export interface HooksKeychainCommandResult {
|
|
18
|
+
/** Exit status; null when the tool could not be started or was killed. */
|
|
19
|
+
status: number | null;
|
|
20
|
+
stdout: string;
|
|
21
|
+
stderr: string;
|
|
22
|
+
}
|
|
23
|
+
/** Tier 3 (Keychain) controls — structurally `KeychainTierOptions`. */
|
|
24
|
+
export interface HooksKeychainTierOptions {
|
|
25
|
+
/** Whether the Keychain is consulted for a caller-built env object. */
|
|
26
|
+
enabled?: boolean;
|
|
27
|
+
/** Defaults to `process.platform`; the tier exists only on `"darwin"`. */
|
|
28
|
+
platform?: string;
|
|
29
|
+
/** The machine's host name when `HASNA_STATION` is unset. */
|
|
30
|
+
hostname?: () => string;
|
|
31
|
+
/** The `security` runner. Defaults to spawning `/usr/bin/security`. */
|
|
32
|
+
run?: (argv: readonly string[]) => HooksKeychainCommandResult;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Tier-1 credential inputs and Keychain-tier controls — structurally
|
|
36
|
+
* `CredentialChainOptions` from @hasna/contracts/client, spelled locally.
|
|
37
|
+
*/
|
|
38
|
+
export interface HooksCredentialOptions {
|
|
39
|
+
/** Tier 1: an explicit key, e.g. from `--api-key`. */
|
|
40
|
+
apiKey?: string;
|
|
41
|
+
/** Tier 1: an explicit profile name. Beats `HASNA_PROFILE`. */
|
|
42
|
+
profile?: string;
|
|
43
|
+
/** Tier 3: Keychain controls — a fake `security` runner in tests, an opt-out on CI. */
|
|
44
|
+
keychain?: HooksKeychainTierOptions;
|
|
45
|
+
}
|
package/dist/lib/sync.d.ts
CHANGED
|
@@ -6,7 +6,16 @@
|
|
|
6
6
|
* failure mid-sync leaves the local store untouched. Local-only hooks are
|
|
7
7
|
* never deleted by a remote sync.
|
|
8
8
|
*/
|
|
9
|
+
import type { HooksCredentialOptions } from "./resolver-types.js";
|
|
9
10
|
import { parseManifest } from "./manifest.js";
|
|
11
|
+
type Env = Record<string, string | undefined>;
|
|
12
|
+
export interface HooksSyncOptions {
|
|
13
|
+
dryRun?: boolean;
|
|
14
|
+
/** Injectable environment (tests). Defaults to `process.env`. */
|
|
15
|
+
env?: Env;
|
|
16
|
+
/** Injectable credential-chain inputs (tests: fake `security` runner). */
|
|
17
|
+
credentials?: HooksCredentialOptions;
|
|
18
|
+
}
|
|
10
19
|
export interface SyncDiff {
|
|
11
20
|
added: string[];
|
|
12
21
|
updated: string[];
|
|
@@ -39,12 +48,8 @@ interface RemoteLock {
|
|
|
39
48
|
versions?: string[];
|
|
40
49
|
}>;
|
|
41
50
|
}
|
|
42
|
-
export declare function planSync(options?:
|
|
43
|
-
|
|
44
|
-
}): Promise<SyncPlan>;
|
|
45
|
-
export declare function syncHooks(options?: {
|
|
46
|
-
dryRun?: boolean;
|
|
47
|
-
}): Promise<SyncPlan>;
|
|
51
|
+
export declare function planSync(options?: HooksSyncOptions): Promise<SyncPlan>;
|
|
52
|
+
export declare function syncHooks(options?: HooksSyncOptions): Promise<SyncPlan>;
|
|
48
53
|
/**
|
|
49
54
|
* One fully-validated artifact ready to be committed. Nothing has touched
|
|
50
55
|
* the store at this point: every fetch, sha check and manifest parse has
|
|
@@ -64,7 +69,7 @@ export interface StagedSyncArtifact {
|
|
|
64
69
|
* written. A failure here (network, sha mismatch, malformed manifest,
|
|
65
70
|
* containment violation) throws with the store untouched.
|
|
66
71
|
*/
|
|
67
|
-
export declare function stageSyncArtifacts(apiUrl: string, names: string[], remoteLock: RemoteLock): Promise<StagedSyncArtifact[]>;
|
|
72
|
+
export declare function stageSyncArtifacts(apiUrl: string, apiKey: string, names: string[], remoteLock: RemoteLock): Promise<StagedSyncArtifact[]>;
|
|
68
73
|
/**
|
|
69
74
|
* P1-9 commit phase, ordered so a mid-commit failure cannot leave a partial
|
|
70
75
|
* store that reads as trusted:
|
|
@@ -101,5 +106,5 @@ export interface PinnedHookInstall {
|
|
|
101
106
|
* the user is fetched from the versioned registry — older-than-latest pins
|
|
102
107
|
* are first-class, never rejected as "not the latest".
|
|
103
108
|
*/
|
|
104
|
-
export declare function fetchPinnedHook(name: string, version: string, apiUrl: string): Promise<PinnedHookInstall>;
|
|
109
|
+
export declare function fetchPinnedHook(name: string, version: string, apiUrl: string, apiKey: string): Promise<PinnedHookInstall>;
|
|
105
110
|
export {};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { HooksCredentialOptions, HooksLocalOptInEnv } from "./resolver-types.js";
|
|
2
|
+
/** The unhosted mode: bundled registry + local SQLite store. Never a default. */
|
|
3
|
+
export type HooksTransportMode = "remote" | "local";
|
|
4
|
+
/** The resolved remote-registry authority pair. Never carries a value besides `apiKey`. */
|
|
5
|
+
export interface HooksRemoteAuthority {
|
|
6
|
+
/**
|
|
7
|
+
* Registry origin WITHOUT the `/v1` suffix the resolver appends — the sync
|
|
8
|
+
* client composes `/api/v1/...` routes on top of it.
|
|
9
|
+
*/
|
|
10
|
+
origin: string;
|
|
11
|
+
/** The credential, resolved together with the authority it will be sent to. */
|
|
12
|
+
apiKey: string;
|
|
13
|
+
/** WHERE the authority came from: an env key NAME, a Keychain item reference, a file PATH, or "default". */
|
|
14
|
+
apiUrlSource: string | null;
|
|
15
|
+
/** WHERE the credential came from: an env key NAME, a Keychain item reference, or a file PATH. Never a value. */
|
|
16
|
+
apiKeySource: string | null;
|
|
17
|
+
/** Which tier of the chain supplied the credential. */
|
|
18
|
+
apiKeyTier: string | null;
|
|
19
|
+
/** The resolver's `<origin>/v1` base, for diagnostics. */
|
|
20
|
+
v1BaseUrl: string;
|
|
21
|
+
/** Human-readable warning, or null. Never contains secret values. */
|
|
22
|
+
warning: string | null;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* The transport decision every hosted surface makes: remote (resolver-backed)
|
|
26
|
+
* or local (explicit opt-in, resolver never consulted).
|
|
27
|
+
*
|
|
28
|
+
* Local mode returns `authority: null` and carries no key. Remote mode always
|
|
29
|
+
* carries a fully resolved pair — the seam throws before returning a
|
|
30
|
+
* half-configured one.
|
|
31
|
+
*/
|
|
32
|
+
export interface HooksTransportResolution {
|
|
33
|
+
mode: HooksTransportMode;
|
|
34
|
+
/** `"local-opt-in"` for the deliberate unhosted store, else `"<api key source>+<api url source>"`. */
|
|
35
|
+
source: string;
|
|
36
|
+
/** The resolved remote pair; null in local mode. */
|
|
37
|
+
authority: HooksRemoteAuthority | null;
|
|
38
|
+
}
|
|
39
|
+
/** Where the one-line local-mode notice goes. Defaults to `process.stderr`. */
|
|
40
|
+
export type HooksTransportNotice = (line: string) => void;
|
|
41
|
+
/** Reset the once-per-process local-mode notice. Test seam only. */
|
|
42
|
+
export declare function __resetHooksLocalNotice(): void;
|
|
43
|
+
/**
|
|
44
|
+
* Announce the unhosted mode on stderr, once per process. Surfaces that never
|
|
45
|
+
* resolve the transport (the CLI gate opening through the opt-in) still have
|
|
46
|
+
* to SAY the run is local — the "local on stderr" doctrine (2026-09-04).
|
|
47
|
+
*/
|
|
48
|
+
export declare function announceHooksLocalMode(reason?: string, notice?: HooksTransportNotice): void;
|
|
49
|
+
/** Strip the `/v1` suffix the resolver normalised onto the authority. */
|
|
50
|
+
export declare function hooksRegistryOrigin(v1BaseUrl: string): string;
|
|
51
|
+
/**
|
|
52
|
+
* Re-throw a `@hasna/contracts` resolution failure as hooks' own fail-closed
|
|
53
|
+
* diagnostic, preserving the resolver's message (which names every tier it
|
|
54
|
+
* consulted) behind the stable `REMOTE_API_*` code callers match on. Nothing
|
|
55
|
+
* here ever returns a client or a local store: every arm throws.
|
|
56
|
+
*/
|
|
57
|
+
export declare function rethrowHooksAuthorityFailure(error: unknown): never;
|
|
58
|
+
/** Tier-1 credential inputs and Keychain-tier controls, forwarded verbatim. */
|
|
59
|
+
export type { HooksCredentialOptions } from "./resolver-types.js";
|
|
60
|
+
export interface HooksTransportOptions {
|
|
61
|
+
/** Tier-1 credential inputs (`--api-key` / `--profile`) and the injectable `security` runner tests use. */
|
|
62
|
+
credentials?: HooksCredentialOptions;
|
|
63
|
+
/** Where the one-line local-mode notice goes. Defaults to `process.stderr`. */
|
|
64
|
+
notice?: HooksTransportNotice;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Resolve the hooks transport. The deliberate unhosted opt-in is answered
|
|
68
|
+
* first and WITHOUT consulting the resolver; otherwise `@hasna/contracts`
|
|
69
|
+
* resolves the credential AND the authority together as one strict pair, and
|
|
70
|
+
* any failure to do so is a throw — the client never defaults to the on-box
|
|
71
|
+
* store (owner ruling 2026-09-04). Resolved fresh on every call.
|
|
72
|
+
*/
|
|
73
|
+
export declare function resolveHooksTransport(env?: HooksLocalOptInEnv, options?: HooksTransportOptions): HooksTransportResolution;
|
|
74
|
+
/**
|
|
75
|
+
* The registry keys the publish surface checks. Local-by-design commands
|
|
76
|
+
* (`hooks serve`) never pick a transport; they only need to know whether a
|
|
77
|
+
* credential exists to honour a PUT. Resolved fresh on every call, through the
|
|
78
|
+
* same chain as {@link resolveHooksTransport}, so a key rotation heals a
|
|
79
|
+
* long-lived server without a restart.
|
|
80
|
+
*
|
|
81
|
+
* Returns `undefined` when no credential resolves — reads stay open and
|
|
82
|
+
* publish refuses — and THROWS on a deliberate tier that exists but cannot be
|
|
83
|
+
* honoured (a locked Keychain, an unsafe credential file), because falling
|
|
84
|
+
* through to another identity is the one silent failure the chain exists to
|
|
85
|
+
* end.
|
|
86
|
+
*/
|
|
87
|
+
export declare function resolveHooksServePublishKey(env?: HooksLocalOptInEnv, options?: HooksTransportOptions): string | undefined;
|
package/dist/openapi.d.ts
CHANGED
|
@@ -209,7 +209,7 @@ export declare const openApiDocument: {
|
|
|
209
209
|
readonly apiKey: {
|
|
210
210
|
readonly type: "http";
|
|
211
211
|
readonly scheme: "bearer";
|
|
212
|
-
readonly description: "The registry API key (HASNA_HOOKS_API_KEY /
|
|
212
|
+
readonly description: "The registry API key — resolved by the client through the @hasna/contracts chain (HASNA_HOOKS_API_KEY, the Keychain item hasna.credentials.hooks.api-key, or ~/.hasna/hooks/config/credentials); the server compares the inbound bearer/x-api-key value.";
|
|
213
213
|
};
|
|
214
214
|
};
|
|
215
215
|
};
|
package/dist/serve.d.ts
CHANGED
|
@@ -31,6 +31,13 @@ export interface ArtifactPayload {
|
|
|
31
31
|
script: string;
|
|
32
32
|
}
|
|
33
33
|
export declare function handleServeRequest(req: Request, apiKey: string | undefined): Promise<Response>;
|
|
34
|
+
export declare function resolveServeOptions(options: {
|
|
35
|
+
port?: number;
|
|
36
|
+
host?: string;
|
|
37
|
+
}): {
|
|
38
|
+
port: number;
|
|
39
|
+
host: string;
|
|
40
|
+
};
|
|
34
41
|
export declare function startServeServer(options: {
|
|
35
42
|
port?: number;
|
|
36
43
|
host?: string;
|
package/dist/storage.js
CHANGED
|
@@ -15,6 +15,123 @@ var __export = (target, all) => {
|
|
|
15
15
|
};
|
|
16
16
|
var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
|
|
17
17
|
|
|
18
|
+
// src/lib/app-home.ts
|
|
19
|
+
import { existsSync } from "fs";
|
|
20
|
+
import { homedir } from "os";
|
|
21
|
+
import { join, resolve } from "path";
|
|
22
|
+
import { homedir as pathsResolverHomedir } from "os";
|
|
23
|
+
import { join as pathsResolverJoin } from "path";
|
|
24
|
+
function pathsResolverAssertApp(app) {
|
|
25
|
+
if (typeof app !== "string" || app.length === 0) {
|
|
26
|
+
throw new TypeError("paths: app must be a non-empty string");
|
|
27
|
+
}
|
|
28
|
+
if (!PATHS_RESOLVER_APP_SLUG_RE.test(app)) {
|
|
29
|
+
throw new TypeError(`paths: invalid app slug "${app}" \u2014 expected lowercase kebab-case ([a-z0-9]+(-[a-z0-9]+)*)`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function pathsResolverAssertKind(kind) {
|
|
33
|
+
if (!Object.keys(PATHS_RESOLVER_KIND_ENV).includes(kind)) {
|
|
34
|
+
throw new TypeError(`paths: invalid path kind "${kind}" \u2014 expected one of ${Object.keys(PATHS_RESOLVER_KIND_ENV).join(", ")}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function pathsResolverBaseDir(kind, options) {
|
|
38
|
+
pathsResolverAssertKind(kind);
|
|
39
|
+
const env = options.env ?? process.env;
|
|
40
|
+
const override = env[PATHS_RESOLVER_KIND_ENV[kind]];
|
|
41
|
+
if (typeof override === "string" && override.length > 0)
|
|
42
|
+
return override;
|
|
43
|
+
const home = options.home ?? pathsResolverHomedir();
|
|
44
|
+
const platform = options.platform ?? process.platform;
|
|
45
|
+
if (platform === "darwin") {
|
|
46
|
+
switch (kind) {
|
|
47
|
+
case "config":
|
|
48
|
+
case "data":
|
|
49
|
+
return pathsResolverJoin(home, "Library", "Application Support", "Hasna");
|
|
50
|
+
case "cache":
|
|
51
|
+
return pathsResolverJoin(home, "Library", "Caches", "Hasna");
|
|
52
|
+
case "state":
|
|
53
|
+
return pathsResolverJoin(home, "Library", "Logs", "Hasna");
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
switch (kind) {
|
|
57
|
+
case "config":
|
|
58
|
+
return pathsResolverJoin(home, ".config", "hasna");
|
|
59
|
+
case "data":
|
|
60
|
+
return pathsResolverJoin(home, ".local", "share", "hasna");
|
|
61
|
+
case "state":
|
|
62
|
+
return pathsResolverJoin(home, ".local", "state", "hasna");
|
|
63
|
+
case "cache":
|
|
64
|
+
return pathsResolverJoin(home, ".cache", "hasna");
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function pathsResolverResolve(kind, options) {
|
|
68
|
+
pathsResolverAssertApp(options.app);
|
|
69
|
+
const appSegment = options.internal === true ? pathsResolverJoin("internal", options.app) : options.app;
|
|
70
|
+
return pathsResolverJoin(pathsResolverBaseDir(kind, options), appSegment);
|
|
71
|
+
}
|
|
72
|
+
function dataDir(options) {
|
|
73
|
+
return pathsResolverResolve("data", options);
|
|
74
|
+
}
|
|
75
|
+
function getHomeDir(env = process.env) {
|
|
76
|
+
return env.HOME || env.USERPROFILE || homedir();
|
|
77
|
+
}
|
|
78
|
+
function getResolverDataRoot(env = process.env) {
|
|
79
|
+
return dataDir({ app: "hooks", home: getHomeDir(env), env });
|
|
80
|
+
}
|
|
81
|
+
function getLegacyDataRoot(env = process.env) {
|
|
82
|
+
return join(getHomeDir(env), ".hasna", "hooks");
|
|
83
|
+
}
|
|
84
|
+
function adoptResolverDataRoot(resolved, env = process.env) {
|
|
85
|
+
const dataOverride = env.HASNA_DATA_HOME;
|
|
86
|
+
if (typeof dataOverride === "string" && dataOverride.trim().length > 0)
|
|
87
|
+
return true;
|
|
88
|
+
return existsSync(join(resolved, STORE_MARKER));
|
|
89
|
+
}
|
|
90
|
+
function getExplicitDataDir(env = process.env) {
|
|
91
|
+
const dir = env.HASNA_HOOKS_DATA_DIR ?? env.HOOKS_DATA_DIR;
|
|
92
|
+
if (typeof dir === "string" && dir.trim().length > 0)
|
|
93
|
+
return dir.trim();
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
function getExactDataRoot(env = process.env) {
|
|
97
|
+
const dir = env.HASNA_HOOKS_HOME?.trim() || env.HOOKS_HOME?.trim();
|
|
98
|
+
if (dir)
|
|
99
|
+
return resolve(dir);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
function getEffectiveDataRoot(env = process.env) {
|
|
103
|
+
const explicit = getExplicitDataDir(env);
|
|
104
|
+
if (explicit)
|
|
105
|
+
return explicit;
|
|
106
|
+
const exact = getExactDataRoot(env);
|
|
107
|
+
if (exact)
|
|
108
|
+
return exact;
|
|
109
|
+
const resolved = getResolverDataRoot(env);
|
|
110
|
+
return adoptResolverDataRoot(resolved, env) ? resolve(resolved) : getLegacyDataRoot(env);
|
|
111
|
+
}
|
|
112
|
+
function getReportedDbPath(env = process.env) {
|
|
113
|
+
const explicit = getExplicitDbPath(env);
|
|
114
|
+
if (explicit)
|
|
115
|
+
return explicit;
|
|
116
|
+
return join(getEffectiveDataRoot(env), "hooks.db");
|
|
117
|
+
}
|
|
118
|
+
function getExplicitDbPath(env = process.env) {
|
|
119
|
+
const db = env.HASNA_HOOKS_DB_PATH ?? env.HOOKS_DB_PATH;
|
|
120
|
+
if (typeof db === "string" && db.trim().length > 0)
|
|
121
|
+
return db.trim();
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
var PATHS_RESOLVER_KIND_ENV, PATHS_RESOLVER_APP_SLUG_RE, STORE_MARKER = "hooks.db";
|
|
125
|
+
var init_app_home = __esm(() => {
|
|
126
|
+
PATHS_RESOLVER_KIND_ENV = {
|
|
127
|
+
config: "HASNA_CONFIG_HOME",
|
|
128
|
+
data: "HASNA_DATA_HOME",
|
|
129
|
+
state: "HASNA_STATE_HOME",
|
|
130
|
+
cache: "HASNA_CACHE_HOME"
|
|
131
|
+
};
|
|
132
|
+
PATHS_RESOLVER_APP_SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
133
|
+
});
|
|
134
|
+
|
|
18
135
|
// src/db/schema.ts
|
|
19
136
|
var CREATE_HOOK_EVENTS_TABLE = `
|
|
20
137
|
CREATE TABLE IF NOT EXISTS hook_events (
|
|
@@ -209,9 +326,9 @@ var init_migrations = __esm(() => {
|
|
|
209
326
|
});
|
|
210
327
|
|
|
211
328
|
// src/db/legacy-import.ts
|
|
212
|
-
import { existsSync, readdirSync, readFileSync } from "fs";
|
|
213
|
-
import { join } from "path";
|
|
214
|
-
import { homedir } from "os";
|
|
329
|
+
import { existsSync as existsSync2, readdirSync, readFileSync } from "fs";
|
|
330
|
+
import { join as join2 } from "path";
|
|
331
|
+
import { homedir as homedir2 } from "os";
|
|
215
332
|
function ensureMetaTable(db) {
|
|
216
333
|
db.exec(`
|
|
217
334
|
CREATE TABLE IF NOT EXISTS _meta (
|
|
@@ -285,25 +402,25 @@ function importErrorsLog(db, filePath) {
|
|
|
285
402
|
} catch {}
|
|
286
403
|
return count;
|
|
287
404
|
}
|
|
288
|
-
function runLegacyImport(db, homeDir =
|
|
405
|
+
function runLegacyImport(db, homeDir = homedir2()) {
|
|
289
406
|
try {
|
|
290
407
|
if (isAlreadyDone(db))
|
|
291
408
|
return;
|
|
292
409
|
let total = 0;
|
|
293
|
-
const claudeProjectsDir =
|
|
294
|
-
if (
|
|
410
|
+
const claudeProjectsDir = join2(homeDir, ".claude", "projects");
|
|
411
|
+
if (existsSync2(claudeProjectsDir)) {
|
|
295
412
|
try {
|
|
296
413
|
const projectDirs = readdirSync(claudeProjectsDir);
|
|
297
414
|
for (const dir of projectDirs) {
|
|
298
|
-
const projectDir =
|
|
415
|
+
const projectDir = join2(claudeProjectsDir, dir);
|
|
299
416
|
try {
|
|
300
417
|
const files = readdirSync(projectDir);
|
|
301
418
|
for (const file of files) {
|
|
302
419
|
if (file.match(/^session-log-\d{4}-\d{2}-\d{2}\.jsonl$/)) {
|
|
303
|
-
total += importJsonlFile(db,
|
|
420
|
+
total += importJsonlFile(db, join2(projectDir, file));
|
|
304
421
|
}
|
|
305
422
|
if (file === "errors.log") {
|
|
306
|
-
total += importErrorsLog(db,
|
|
423
|
+
total += importErrorsLog(db, join2(projectDir, file));
|
|
307
424
|
}
|
|
308
425
|
}
|
|
309
426
|
} catch {}
|
|
@@ -339,31 +456,27 @@ function runRetention(db, days) {
|
|
|
339
456
|
|
|
340
457
|
// src/db/index.ts
|
|
341
458
|
import { Database } from "bun:sqlite";
|
|
342
|
-
import { existsSync as
|
|
343
|
-
import { join as
|
|
344
|
-
import { homedir as homedir2 } from "os";
|
|
459
|
+
import { existsSync as existsSync3, mkdirSync, cpSync } from "fs";
|
|
460
|
+
import { join as join3 } from "path";
|
|
345
461
|
function resolveDataDir() {
|
|
346
|
-
const
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
if (!existsSync2(newDir) && existsSync2(oldDir)) {
|
|
352
|
-
mkdirSync(join2(homedir2(), ".hasna"), { recursive: true });
|
|
353
|
-
cpSync(oldDir, newDir, { recursive: true });
|
|
462
|
+
const effective = getEffectiveDataRoot();
|
|
463
|
+
const oldDir = join3(getHomeDir(), ".hooks");
|
|
464
|
+
if (!existsSync3(effective) && existsSync3(oldDir)) {
|
|
465
|
+
mkdirSync(effective, { recursive: true });
|
|
466
|
+
cpSync(oldDir, effective, { recursive: true });
|
|
354
467
|
}
|
|
355
|
-
return
|
|
468
|
+
return effective;
|
|
356
469
|
}
|
|
357
470
|
function getDbPath() {
|
|
358
471
|
const explicitDb = process.env.HASNA_HOOKS_DB_PATH ?? process.env.HOOKS_DB_PATH;
|
|
359
472
|
if (explicitDb)
|
|
360
473
|
return explicitDb;
|
|
361
|
-
const
|
|
362
|
-
return
|
|
474
|
+
const dataDir2 = resolveDataDir();
|
|
475
|
+
return join3(dataDir2, "hooks.db");
|
|
363
476
|
}
|
|
364
477
|
function ensureDir(dbPath) {
|
|
365
478
|
const dir = dbPath.substring(0, dbPath.lastIndexOf("/"));
|
|
366
|
-
if (dir && !
|
|
479
|
+
if (dir && !existsSync3(dir)) {
|
|
367
480
|
mkdirSync(dir, { recursive: true });
|
|
368
481
|
}
|
|
369
482
|
}
|
|
@@ -371,7 +484,7 @@ function getDb() {
|
|
|
371
484
|
if (instance)
|
|
372
485
|
return instance;
|
|
373
486
|
const dbPath = getDbPath();
|
|
374
|
-
const isNew = dbPath === ":memory:" || !
|
|
487
|
+
const isNew = dbPath === ":memory:" || !existsSync3(dbPath);
|
|
375
488
|
ensureDir(dbPath);
|
|
376
489
|
instance = new Database(dbPath);
|
|
377
490
|
instance.exec("PRAGMA busy_timeout=5000");
|
|
@@ -395,6 +508,7 @@ function getDb() {
|
|
|
395
508
|
}
|
|
396
509
|
var instance = null;
|
|
397
510
|
var init_db = __esm(() => {
|
|
511
|
+
init_app_home();
|
|
398
512
|
init_migrations();
|
|
399
513
|
init_legacy_import();
|
|
400
514
|
});
|
|
@@ -445,8 +559,8 @@ var init_redact = __esm(() => {
|
|
|
445
559
|
/AKIA[0-9A-Z]{16}/g,
|
|
446
560
|
/ASIA[0-9A-Z]{16}/g,
|
|
447
561
|
/eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g,
|
|
448
|
-
/-----BEGIN [A-Z0-9 ]*
|
|
449
|
-
/-----BEGIN OPENSSH
|
|
562
|
+
/-----BEGIN [A-Z0-9 ]*PRIVAT[E] KEY-----/g,
|
|
563
|
+
/-----BEGIN OPENSSH PRIVAT[E] KEY-----/g,
|
|
450
564
|
/\bBearer\s+[A-Za-z0-9._~+/=-]{6,}/g,
|
|
451
565
|
/\b[a-z][a-z0-9+.-]*:\/\/[^/\s:@]+:[^/\s@]+@/gi,
|
|
452
566
|
/\b(?:password|passwd|pwd|token|api[_-]?key|secret|access[_-]?key|client[_-]?secret|authorization|auth|credential|database_url|db_url|connection_string|dsn)\s*[:=]\s*(?:"[^"]*"|'[^']*'|[^\s,;"']{6,})/gi
|