@raidiant/notifai 10.1.1 → 10.1.2

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.
Files changed (156) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/commands-core.d.ts +1 -1
  3. package/dist/commands-init.js +9 -6
  4. package/dist/commands-init.js.map +1 -1
  5. package/dist/commands-send-support.js +3 -0
  6. package/dist/commands-send-support.js.map +1 -1
  7. package/dist/commands-skill.js +72 -54
  8. package/dist/commands-skill.js.map +1 -1
  9. package/dist/invocation-context.d.ts +1 -0
  10. package/dist/invocation-context.js +9 -1
  11. package/dist/invocation-context.js.map +1 -1
  12. package/dist/native-skills.d.ts +13 -3
  13. package/dist/native-skills.js +51 -12
  14. package/dist/native-skills.js.map +1 -1
  15. package/dist/node-floor.d.ts +2 -1
  16. package/dist/node-floor.js +9 -5
  17. package/dist/node-floor.js.map +1 -1
  18. package/dist/orca-session-title.d.ts +5 -4
  19. package/dist/orca-session-title.js +32 -19
  20. package/dist/orca-session-title.js.map +1 -1
  21. package/dist/platform.d.ts +11 -17
  22. package/dist/platform.js +20 -23
  23. package/dist/platform.js.map +1 -1
  24. package/dist/release.d.ts +5 -5
  25. package/dist/release.js +5 -5
  26. package/dist/setup-destinations.js +1 -1
  27. package/dist/setup-destinations.js.map +1 -1
  28. package/dist/skill-integrity.d.ts +49 -0
  29. package/dist/skill-integrity.js +154 -0
  30. package/dist/skill-integrity.js.map +1 -0
  31. package/dist/skill-source/manifest.json +16 -0
  32. package/dist/skill-source/notifai/SKILL.md +390 -0
  33. package/dist/skill-source/notifai/references/harness-setup.md +220 -0
  34. package/package.json +11 -14
  35. package/src/atomic-file.ts +0 -198
  36. package/src/claude-wake.test.ts +0 -321
  37. package/src/claude-wake.ts +0 -499
  38. package/src/cli-bin.test.ts +0 -176
  39. package/src/cli-bin.ts +0 -233
  40. package/src/cli-release.test.ts +0 -46
  41. package/src/cli-release.ts +0 -78
  42. package/src/cli-update-contract.ts +0 -9
  43. package/src/cli-update.test.ts +0 -125
  44. package/src/client.test.ts +0 -390
  45. package/src/client.ts +0 -352
  46. package/src/codex-wake.test.ts +0 -254
  47. package/src/codex-wake.ts +0 -301
  48. package/src/command-session.ts +0 -38
  49. package/src/commands-agent-sessions.ts +0 -72
  50. package/src/commands-auth.ts +0 -238
  51. package/src/commands-config.ts +0 -336
  52. package/src/commands-core.ts +0 -312
  53. package/src/commands-devices.ts +0 -180
  54. package/src/commands-doctor.ts +0 -1501
  55. package/src/commands-guidance.ts +0 -181
  56. package/src/commands-harness-context.ts +0 -207
  57. package/src/commands-hook-shape.ts +0 -69
  58. package/src/commands-hooks.ts +0 -2318
  59. package/src/commands-init.ts +0 -1055
  60. package/src/commands-io.ts +0 -108
  61. package/src/commands-logs.ts +0 -243
  62. package/src/commands-project.ts +0 -47
  63. package/src/commands-send-support.test.ts +0 -156
  64. package/src/commands-send-support.ts +0 -311
  65. package/src/commands-send.ts +0 -875
  66. package/src/commands-setup-proof.test.ts +0 -72
  67. package/src/commands-setup-proof.ts +0 -124
  68. package/src/commands-skill.test.ts +0 -44
  69. package/src/commands-skill.ts +0 -204
  70. package/src/commands-sounds.test.ts +0 -106
  71. package/src/commands-sounds.ts +0 -49
  72. package/src/commands-update.ts +0 -180
  73. package/src/commands.test.ts +0 -8297
  74. package/src/commands.ts +0 -77
  75. package/src/config-schema.test.ts +0 -153
  76. package/src/config-schema.ts +0 -349
  77. package/src/config.test.ts +0 -581
  78. package/src/config.ts +0 -475
  79. package/src/credentials.test.ts +0 -403
  80. package/src/credentials.ts +0 -403
  81. package/src/file-lock-process-worker.test.ts +0 -64
  82. package/src/file-lock.test.ts +0 -320
  83. package/src/file-lock.ts +0 -393
  84. package/src/fixtures/hermes-0.20.6-classic-cli-local.json +0 -50
  85. package/src/fixtures/openclaw-2026.7.1-2-agent-local.json +0 -57
  86. package/src/generated-session-label.ts +0 -155
  87. package/src/guidance-content.ts +0 -278
  88. package/src/guidance-render.ts +0 -56
  89. package/src/guidance.test.ts +0 -327
  90. package/src/guidance.ts +0 -233
  91. package/src/harness-integration.test.ts +0 -294
  92. package/src/harnesses.test.ts +0 -149
  93. package/src/harnesses.ts +0 -164
  94. package/src/hook-adapter.test.ts +0 -496
  95. package/src/hook-adapter.ts +0 -424
  96. package/src/hook-input.test.ts +0 -39
  97. package/src/hook-input.ts +0 -72
  98. package/src/hooks.test.ts +0 -5824
  99. package/src/hooks.ts +0 -4011
  100. package/src/install-hooks.test.ts +0 -1614
  101. package/src/install-hooks.ts +0 -1501
  102. package/src/interactive.test.ts +0 -387
  103. package/src/interactive.ts +0 -858
  104. package/src/invocation-context.test.ts +0 -352
  105. package/src/invocation-context.ts +0 -197
  106. package/src/logging-process-worker.test.ts +0 -45
  107. package/src/logging.test.ts +0 -544
  108. package/src/logging.ts +0 -698
  109. package/src/main-run.ts +0 -45
  110. package/src/main.ts +0 -12
  111. package/src/native-skills.test.ts +0 -115
  112. package/src/native-skills.ts +0 -162
  113. package/src/node-floor.test.ts +0 -27
  114. package/src/node-floor.ts +0 -37
  115. package/src/onboarding-docs.test.ts +0 -25
  116. package/src/openclaw-plugin.ts +0 -409
  117. package/src/opencode-plugin.ts +0 -298
  118. package/src/orca-session-title.test.ts +0 -54
  119. package/src/orca-session-title.ts +0 -103
  120. package/src/pack-feedback-slice.test.ts +0 -212
  121. package/src/pack-feedback-slice.ts +0 -187
  122. package/src/platform.test.ts +0 -152
  123. package/src/platform.ts +0 -173
  124. package/src/program.test.ts +0 -317
  125. package/src/program.ts +0 -842
  126. package/src/project-enablement.test.ts +0 -55
  127. package/src/project-enablement.ts +0 -99
  128. package/src/question-caps.test.ts +0 -49
  129. package/src/question-settlement-process.ts +0 -47
  130. package/src/question-timing.ts +0 -56
  131. package/src/readiness.test.ts +0 -25
  132. package/src/readiness.ts +0 -212
  133. package/src/release.ts +0 -51
  134. package/src/send-attempts.test.ts +0 -143
  135. package/src/send-attempts.ts +0 -188
  136. package/src/send.test.ts +0 -28
  137. package/src/send.ts +0 -436
  138. package/src/session-activation.ts +0 -61
  139. package/src/session-end-log-lock-worker.test.ts +0 -35
  140. package/src/session-labels.test.ts +0 -490
  141. package/src/session-labels.ts +0 -474
  142. package/src/setup-destinations.test.ts +0 -41
  143. package/src/setup-destinations.ts +0 -50
  144. package/src/skill.test.ts +0 -426
  145. package/src/sound-ref.test.ts +0 -31
  146. package/src/sound-ref.ts +0 -34
  147. package/src/trust-policy.test.ts +0 -701
  148. package/src/ui/banner.ts +0 -95
  149. package/src/ui/config-view.test.ts +0 -90
  150. package/src/ui/config-view.ts +0 -138
  151. package/src/ui/help.test.ts +0 -16
  152. package/src/ui/help.ts +0 -128
  153. package/src/ui/theme.test.ts +0 -92
  154. package/src/ui/theme.ts +0 -265
  155. package/src/url-policy.ts +0 -399
  156. package/tsconfig.json +0 -10
@@ -0,0 +1,220 @@
1
+ # Harness setup and recovery
2
+
3
+ The mechanics behind installing Notifai, routing questions to devices, and
4
+ working out why either is not happening. Read it when you are installing,
5
+ diagnosing, or recovering — not before.
6
+
7
+ - [Signing this machine in](#signing-this-machine-in)
8
+ - [Install deliberately](#install-deliberately)
9
+ - [Activation by harness](#activation-by-harness)
10
+ - [How the answer gets back to the agent](#how-the-answer-gets-back-to-the-agent)
11
+ - [Bounded recovery](#bounded-recovery)
12
+ - [Reading the record](#reading-the-record)
13
+ - [Settings and environment](#settings-and-environment)
14
+
15
+ ## Signing this machine in
16
+
17
+ Run `notifai login` yourself. It opens a browser page only the user can approve.
18
+
19
+ In a headless or remote shell, `notifai login --no-open` prints the approval URL
20
+ instead of trying to open a browser — hand the user that URL. `--name <name>`
21
+ sets what the machine is called in their dashboard; the hostname is the default.
22
+
23
+ `notifai auth status --json` says whether this machine is paired.
24
+ `notifai auth access --json` says whether the account has access, including
25
+ access already requested and waiting on a person — not something to ask them to
26
+ do again. They fail differently and are worth separating before you report
27
+ either as broken.
28
+ `notifai logout` removes the stored credential.
29
+
30
+ ## Install deliberately
31
+
32
+ Flagless `notifai init --json` needs no answer and closes everything before
33
+ this point. Hooks place files, so this is where a question belongs: ask setup
34
+ scope and whether they want questions routed, in the same structured question.
35
+ If they do, let init install and assess the hooks in their chosen scope. Never
36
+ tell the user to run setup commands themselves.
37
+
38
+ ```bash
39
+ notifai init --hooks --setup-scope <project|global> --json
40
+ ```
41
+
42
+ A machine-global Notifai skill is guidance, not routing evidence. The active
43
+ harness needs its own installed hook and a current session pointer.
44
+
45
+ Installed definitions call one stable user-level adapter at
46
+ `~/.notifai/bin/hook-adapter`. `hooks install` atomically retargets that adapter
47
+ to the current CLI while leaving definition bytes unchanged across Node/NVM,
48
+ package-manager, CLI-version, checkout, XDG directory, and Notifai preference
49
+ changes. Stop definitions differ by harness: Claude Code's runs asynchronously
50
+ with an explicit timeout above the longest answer window (POSIX hosts only — on
51
+ Windows it blocks like the others), and Codex declares the same full-window
52
+ timeout; prompt-submit and session-end retain fixed short limits on both.
53
+ Migrating an older Codex definition requires one unavoidable `/hooks` approval;
54
+ later upgrades must not require another.
55
+
56
+ `notifai ask --json` owns its admission check. On failure, branch on its stable
57
+ `code`, `check_id`, `exit_code`, and `remedy`; do not run a routine doctor pass
58
+ first. Historical UserPromptSubmit and Stop evidence remains part of that
59
+ fail-closed check. Ask exposes no `--session-id` override and will not guess one.
60
+
61
+ `hooks-wake-route` reports, without probing anything, whether an answer could
62
+ start a turn in this exact Agent Session on its own. It never blocks: when it
63
+ cannot, the answer is held and replayed at the Agent Session's next turn instead.
64
+
65
+ Notifai never writes trust approvals. If its diagnosis and Codex disagree,
66
+ `/hooks` is authoritative.
67
+
68
+ The prepared User message for this human-only action is:
69
+
70
+ > Open `/hooks` in Codex, approve or enable the Notifai handlers, then tell me
71
+ > when it is done. I will finish setup and verify a fresh session.
72
+
73
+ ## Activation by harness
74
+
75
+ - **Claude Code:** run the installer if needed, start one fresh Agent Session,
76
+ send one prompt, then run `notifai doctor`. An already-running Agent Session cannot
77
+ receive newly installed `SessionStart` context. If SessionStart is absent,
78
+ reinstall the current hooks and start a fresh Agent Session; UserPromptSubmit
79
+ records presence and question lifecycle only and never substitutes for
80
+ lifecycle activation. Claude's SubagentStart gives ordinary workers the
81
+ reporting-only context; explicit textual delegation makes a worker load the
82
+ skill and guidance as the new Notification Request owner.
83
+ - **Codex:** run `notifai hooks install --harness codex`. If `hooks-trust`
84
+ fails, open `/hooks` in Codex and approve or enable the Notifai handlers.
85
+ Then start one fresh Agent Session, send one prompt, and run `notifai doctor`. If
86
+ SessionStart is absent, reinstall the current hooks and start a fresh Agent Session;
87
+ UserPromptSubmit does not activate it. Codex SubagentStart uses the same
88
+ reporting-only worker contract and explicit textual delegation rule as
89
+ Claude. Codex resolves project hooks from the main repository. In a
90
+ linked worktree, the installer writes the shared file to
91
+ the main checkout and creates the project-layer `.codex` directory in the
92
+ current worktree; run it once in each new worktree.
93
+ - **Cursor:** start one fresh conversation, send one prompt, and let the first
94
+ completed or errored turn finish. Cursor's `SessionStart` context is currently
95
+ lossy, so one visible synthetic follow-up activates Notifai through its native
96
+ Stop contract; cancellation does not trigger it, and a live question
97
+ continuation takes priority. Then run `notifai doctor`. The agent shell does
98
+ not create a separately activated context for delegated work: it remains
99
+ under the parent Agent Session and its explicit Notification Request ownership. It
100
+ does not expose the exact conversation id needed to prove which concurrent Agent Session
101
+ invoked `notifai ask`, so asynchronous ask fails closed. Use blocking
102
+ `notifai send --reply` for questions.
103
+ - **OpenCode:** restart after installation because plugins load at startup,
104
+ then start one fresh Agent Session, send one prompt, and run `notifai doctor`.
105
+ Notifai owns its generated plugin file and will not overwrite a foreign one.
106
+ The plugin treats a session with `parentID` as a worker. When relationship
107
+ lookup fails or returns unusable data it also fails safe as a non-sending
108
+ worker; only a proven parent Agent Session receives owner context. Explicit textual
109
+ delegation promotes that worker through the same skill-and-guidance rule.
110
+ OpenCode has no locally proven exactly-once continuation after `session.idle`,
111
+ so `notifai ask` fails closed instead of accepting an answer into a void.
112
+ Use a blocking `notifai send --reply` question when its answer must return to
113
+ the agent without another human prompt.
114
+ - **OpenClaw:** restart the Gateway after installation because plugins load at
115
+ startup, then start one fresh Agent Session, send one prompt, and run
116
+ `notifai doctor`. Notifai owns its generated Gateway plugin and will not
117
+ overwrite a foreign one. Exact Agent Session identity is the OpenClaw
118
+ `sessionKey`; `sessionId` is a rotating generation and is never used as the
119
+ session id. A session key containing `:subagent:` or an ACP nested context
120
+ is a worker. Missing identity fails safe as a non-sending worker; only a
121
+ proven parent Agent Session receives owner context. Explicit textual
122
+ delegation promotes that worker through the same skill-and-guidance rule.
123
+ OpenClaw has no locally proven exactly-once continuation after `agent_end`,
124
+ so `notifai ask` fails closed instead of accepting an answer into a void.
125
+ Use a blocking `notifai send --reply` question when its answer must return to
126
+ the agent without another human prompt. Do not treat `/stop` as turn-end.
127
+ - **Hermes:** classic CLI on the local terminal backend can send. Notifai reads
128
+ exact `HERMES_SESSION_ID` and derives git branch and worktree from the actual
129
+ invocation cwd. Do not run `notifai hooks install --harness hermes` — managed
130
+ setup, lifecycle activation, and Question Routing are unsupported. Use a
131
+ blocking `notifai send --reply` question. Install the Notifai skill through
132
+ `npx skills`; no Hermes plugin may vendor a copy. Nested inherited harness
133
+ markers fail closed. TUI, gateway, API, ACP, and remote backends are not
134
+ this baseline.
135
+
136
+ Do not claim managed hook, activation, or Question Routing support for a
137
+ harness that is absent from `notifai hooks install --help`. Classic Hermes CLI
138
+ can send with Source Context without those.
139
+
140
+ ## How the answer gets back to the agent
141
+
142
+ The session that registered a question owns the answer's return. The last
143
+ meter differs per harness:
144
+
145
+ - **Claude Code:** the Stop hook is asynchronous. It returns at once, so the
146
+ turn is never held and the terminal stays the user's, and the same process
147
+ keeps waiting out of band for the complete answer window. When the answer
148
+ arrives it is posted to that Agent Session's own inbox socket: an idle Agent
149
+ Session starts a new turn with it, and a busy one receives it when its current
150
+ turn ends. An Agent Session that is provably gone is cold-resumed instead —
151
+ never one whose liveness probe cannot rule it out.
152
+ - **Codex:** the Stop hook is the waiter. It holds until the answer arrives or
153
+ the complete answer window ends, then continues the session by returning
154
+ `decision: block`.
155
+ - **Crash recovery:** the answer journal protects an accepted answer if an
156
+ owner process or its route fails. It is not the normal last meter for an
157
+ unexpired question.
158
+
159
+ An accepted answer is never dropped and never delivered twice, so a question
160
+ that has not come back yet is still coming: do not re-ask it.
161
+
162
+ At the `ask_grace_seconds` default of `0`, the question reaches devices as soon
163
+ as the asking turn ends. A positive value keeps it in the terminal for that
164
+ long first, so an answer typed there wins without a notification ever leaving.
165
+ `reply_window_seconds` then controls how long the answer is accepted and how
166
+ long Question Routing keeps an exact return path to this Agent Session. The
167
+ grace window is skipped when a question from this Agent Session is already
168
+ waiting on the user's devices: they have been interrupted already, and holding
169
+ the second question back would only delay it.
170
+
171
+ ## Bounded recovery
172
+
173
+ Follow the exact `notifai doctor` diagnostic. Common recovery is one repair,
174
+ one fresh activation, and one new doctor check. Stop if the current pointer
175
+ belongs to another active session or if the hook still has not fired; ask the
176
+ user or coordinator instead of retrying indefinitely.
177
+
178
+ If a companion device is missing, ask the user to open a supported companion
179
+ build, sign in, and grant notification permission. Do not emulate that, and do
180
+ not treat Provider Acceptance as Companion Receipt proof.
181
+
182
+ To stop routing questions from this project, `notifai hooks uninstall`; add
183
+ `--global` to remove a machine-wide install, and `--harness <name>` to name one
184
+ when several are wired.
185
+
186
+ ## Reading the record
187
+
188
+ `notifai logs` narrows several ways, and they compose:
189
+
190
+ - `--request <id>` · `--run <id>` · `--session <id>` — one notification, one
191
+ invocation, one Agent Session
192
+ - `--event <name>` (repeatable) · `--grep <text>` · `--level error`
193
+ - `--since 10m|2h|1d|<ISO 8601>` · `-n <count>` · `--all` · `--project <id>` ·
194
+ `--all-projects`
195
+ - `--json` for one record per line, `--path` for where the files are
196
+
197
+ `--clear` deletes the user's local record. It is theirs, and nothing else keeps
198
+ a copy — do not run it to tidy up.
199
+
200
+ ## Settings and environment
201
+
202
+ `notifai config explain <key> [--json]` gives the full explanation of one
203
+ setting; `notifai config show --explain` includes advanced keys and the file
204
+ each value came from. Beyond the usual scopes, `--session <id>` writes a
205
+ preference that lasts only for one Agent Session.
206
+
207
+ `ask_notifications` is the setting that turns question routing off for a scope;
208
+ `ask_grace_seconds` is the terminal-first window described above;
209
+ `reply_window_seconds` is how long an answer is still accepted, a day by
210
+ default and up to three.
211
+
212
+ Those are three different controls and only the last one decides whether an
213
+ answer is still wanted. Question Routing owns that complete window: Claude Code
214
+ waits out of band and wakes the Agent Session, while Codex keeps the asking turn
215
+ held. The journal is crash recovery, not the ordinary delivery path.
216
+
217
+ `NOTIFAI_NO_INPUT=1` guarantees no command will ever prompt, which is what you
218
+ want in CI or any shell with nobody at it. `NOTIFAI_CREDENTIALS=file` stores the
219
+ machine credential in a plaintext file rather than the OS keychain — only when
220
+ the user has asked for it, and never on a shared machine.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@raidiant/notifai",
3
- "version": "10.1.1",
3
+ "version": "10.1.2",
4
4
  "type": "module",
5
5
  "description": "Send native device notifications from agents and local programs",
6
6
  "bin": {
@@ -8,20 +8,11 @@
8
8
  },
9
9
  "files": [
10
10
  "dist",
11
- "src",
12
- "tsconfig.json",
13
11
  "CHANGELOG.md"
14
12
  ],
15
- "scripts": {
16
- "build": "node ../../scripts/clean-dist.mjs && tsc -p tsconfig.json && node ../../scripts/chmod-cli-bin.mjs",
17
- "pretest": "pnpm run build",
18
- "typecheck": "tsc -p tsconfig.json --noEmit",
19
- "test": "node ../../scripts/run-cli-tests.mjs",
20
- "prepack": "pnpm run build"
21
- },
22
13
  "dependencies": {
23
14
  "@clack/prompts": "^1.7.0",
24
- "@raidiant/notifai-protocol": "6.0.2",
15
+ "@raidiant/notifai-protocol": "6.0.3",
25
16
  "commander": "^14.0.0",
26
17
  "picocolors": "^1.1.1",
27
18
  "smol-toml": "^1.4.0"
@@ -31,7 +22,7 @@
31
22
  "vitest": "^3.2.0"
32
23
  },
33
24
  "engines": {
34
- "node": ">=22"
25
+ "node": ">=20.12.0"
35
26
  },
36
27
  "license": "Apache-2.0",
37
28
  "author": "Rafael Vidaurre",
@@ -58,5 +49,11 @@
58
49
  "ai",
59
50
  "claude",
60
51
  "codex"
61
- ]
62
- }
52
+ ],
53
+ "scripts": {
54
+ "build": "node ../../scripts/clean-dist.mjs && tsc -p tsconfig.json && node ../../scripts/copy-shipped-skill.mjs && node ../../scripts/chmod-cli-bin.mjs",
55
+ "pretest": "pnpm run build",
56
+ "typecheck": "tsc -p tsconfig.json --noEmit",
57
+ "test": "node ../../scripts/run-cli-tests.mjs"
58
+ }
59
+ }
@@ -1,198 +0,0 @@
1
- import { randomBytes } from 'node:crypto'
2
- import {
3
- chmodSync,
4
- closeSync,
5
- fsyncSync,
6
- lstatSync,
7
- mkdirSync,
8
- openSync,
9
- renameSync,
10
- unlinkSync,
11
- writeFileSync,
12
- } from 'node:fs'
13
- import path from 'node:path'
14
-
15
- export interface AtomicWriteOptions {
16
- /** Mode for a newly created file. Existing regular files keep their mode. */
17
- mode?: number
18
- /** False when a managed executable must keep the requested mode on repair. */
19
- preserveMode?: boolean
20
- /** Reject a target owned by another user instead of replacing it. */
21
- requireCurrentUserOwner?: boolean
22
- /**
23
- * POSIX directory mode applied to the parent. Defaults to 0o700 when
24
- * `requireCurrentUserOwner` is set so credential and config dirs match the
25
- * hook-adapter and log directories.
26
- */
27
- directoryMode?: number
28
- }
29
-
30
- /**
31
- * Replace one regular file without exposing a truncated intermediate state.
32
- *
33
- * The temporary file is a unique sibling, so rename is atomic on every
34
- * supported filesystem. The contents are flushed before the rename, and a
35
- * symlink target is refused instead of silently writing through it.
36
- */
37
- export function atomicWriteFileSync(
38
- file: string,
39
- contents: string,
40
- options: AtomicWriteOptions = {},
41
- ): void {
42
- const directory = path.dirname(file)
43
- const requireCurrentUserOwner = options.requireCurrentUserOwner ?? false
44
- const directoryMode = options.directoryMode ?? (requireCurrentUserOwner ? 0o700 : undefined)
45
- const parent = ensureDirectory(directory, {
46
- requireCurrentUserOwner,
47
- ...(directoryMode === undefined ? {} : { mode: directoryMode }),
48
- })
49
- const target = targetMetadata(
50
- file,
51
- options.mode ?? 0o600,
52
- options.preserveMode ?? true,
53
- options.requireCurrentUserOwner ?? false,
54
- )
55
- const temp = path.join(
56
- directory,
57
- `.${path.basename(file)}.notifai-${process.pid}-${randomBytes(6).toString('hex')}.tmp`,
58
- )
59
- let handle: number | undefined
60
- try {
61
- handle = openSync(temp, 'wx', target.mode)
62
- writeFileSync(handle, contents)
63
- fsyncSync(handle)
64
- closeSync(handle)
65
- handle = undefined
66
- chmodSync(temp, target.mode)
67
- assertSameDirectory(directory, parent)
68
- assertUnchangedTarget(file, target)
69
- renameSync(temp, file)
70
- syncDirectory(directory)
71
- } catch (err) {
72
- if (handle !== undefined) closeSync(handle)
73
- try {
74
- unlinkSync(temp)
75
- } catch {
76
- // Preserve the original error; the temp may not have been created.
77
- }
78
- throw err
79
- }
80
- }
81
-
82
- /**
83
- * Create (or tighten) a directory that must not be world-readable.
84
- *
85
- * `mkdir` mode applies only to a newly created leaf, so an existing 0755
86
- * credential directory would otherwise stay world-listable. POSIX chmod of
87
- * the leaf is the matching repair; Windows has no equivalent mode bits.
88
- */
89
- export function ensurePrivateDirectory(directory: string): void {
90
- ensureDirectory(directory, { mode: 0o700, requireCurrentUserOwner: false })
91
- }
92
-
93
- function ensureDirectory(
94
- directory: string,
95
- options: { mode?: number; requireCurrentUserOwner: boolean },
96
- ): { dev: number; ino: number } {
97
- if (options.mode === undefined) mkdirSync(directory, { recursive: true })
98
- else mkdirSync(directory, { recursive: true, mode: options.mode })
99
- const parent = safeDirectory(directory, options.requireCurrentUserOwner)
100
- if (options.mode !== undefined && process.platform !== 'win32') {
101
- chmodSync(directory, options.mode)
102
- }
103
- return parent
104
- }
105
-
106
- /** Persist the published directory entry where the host supports directory fsync. */
107
- function syncDirectory(directory: string): void {
108
- if (process.platform === 'win32') return
109
- const handle = openSync(directory, 'r')
110
- try {
111
- fsyncSync(handle)
112
- } finally {
113
- closeSync(handle)
114
- }
115
- }
116
-
117
- /** Mode to preserve, or the mode for a new file. Refuses a non-regular target. */
118
- interface TargetMetadata {
119
- mode: number
120
- identity: { dev: number; ino: number } | null
121
- }
122
-
123
- function targetMetadata(
124
- file: string,
125
- fallback: number,
126
- preserveMode: boolean,
127
- requireCurrentUserOwner: boolean,
128
- ): TargetMetadata {
129
- let stat
130
- try {
131
- stat = lstatSync(file)
132
- } catch (err) {
133
- if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
134
- return { mode: fallback, identity: null }
135
- }
136
- throw err
137
- }
138
- if (stat.isSymbolicLink()) {
139
- throw new Error(
140
- `${file} is a symlink; refusing to write through it. Replace it with a regular file, ` +
141
- 'or use a different state directory.',
142
- )
143
- }
144
- if (!stat.isFile()) throw new Error(`${file} is not a regular file; refusing to replace it.`)
145
- if (requireCurrentUserOwner) assertCurrentUserOwns(file, stat.uid)
146
- return {
147
- mode: preserveMode ? stat.mode & 0o777 : fallback,
148
- identity: { dev: stat.dev, ino: stat.ino },
149
- }
150
- }
151
-
152
- function assertUnchangedTarget(file: string, target: TargetMetadata): void {
153
- let current
154
- try {
155
- current = lstatSync(file)
156
- } catch (err) {
157
- if ((err as NodeJS.ErrnoException).code === 'ENOENT' && target.identity === null) return
158
- throw new Error(`${file} changed during the atomic write; refusing to replace it.`)
159
- }
160
- if (
161
- target.identity === null ||
162
- current.dev !== target.identity.dev ||
163
- current.ino !== target.identity.ino
164
- ) {
165
- throw new Error(`${file} changed during the atomic write; refusing to replace it.`)
166
- }
167
- }
168
-
169
- function safeDirectory(
170
- directory: string,
171
- requireCurrentUserOwner: boolean,
172
- ): { dev: number; ino: number } {
173
- const stat = lstatSync(directory)
174
- if (stat.isSymbolicLink() || !stat.isDirectory()) {
175
- throw new Error(`${directory} is not a regular directory; refusing to write inside it.`)
176
- }
177
- if (requireCurrentUserOwner) assertCurrentUserOwns(directory, stat.uid)
178
- return { dev: stat.dev, ino: stat.ino }
179
- }
180
-
181
- function assertSameDirectory(directory: string, expected: { dev: number; ino: number }): void {
182
- const current = lstatSync(directory)
183
- if (
184
- current.isSymbolicLink() ||
185
- !current.isDirectory() ||
186
- current.dev !== expected.dev ||
187
- current.ino !== expected.ino
188
- ) {
189
- throw new Error(`${directory} changed during the write; refusing to publish the file.`)
190
- }
191
- }
192
-
193
- function assertCurrentUserOwns(file: string, owner: number): void {
194
- const uid = typeof process.getuid === 'function' ? process.getuid() : undefined
195
- if (uid !== undefined && owner !== uid) {
196
- throw new Error(`${file} is owned by uid ${owner}, not the current user; refusing to replace it.`)
197
- }
198
- }