@raidiant/notifai 10.1.0 → 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.
- package/CHANGELOG.md +37 -0
- package/README.md +3 -0
- package/dist/cli-bin.d.ts +21 -1
- package/dist/cli-bin.js +141 -10
- package/dist/cli-bin.js.map +1 -1
- package/dist/cli-update-contract.d.ts +7 -0
- package/dist/cli-update-contract.js +10 -0
- package/dist/cli-update-contract.js.map +1 -0
- package/dist/client.d.ts +6 -1
- package/dist/client.js +1 -0
- package/dist/client.js.map +1 -1
- package/dist/codex-wake.d.ts +2 -0
- package/dist/codex-wake.js +2 -1
- package/dist/codex-wake.js.map +1 -1
- package/dist/commands-auth.d.ts +15 -1
- package/dist/commands-auth.js +29 -11
- package/dist/commands-auth.js.map +1 -1
- package/dist/commands-core.d.ts +9 -2
- package/dist/commands-core.js +11 -4
- package/dist/commands-core.js.map +1 -1
- package/dist/commands-devices.d.ts +10 -1
- package/dist/commands-devices.js +17 -13
- package/dist/commands-devices.js.map +1 -1
- package/dist/commands-doctor.js +93 -49
- package/dist/commands-doctor.js.map +1 -1
- package/dist/commands-hooks.js +47 -10
- package/dist/commands-hooks.js.map +1 -1
- package/dist/commands-init.d.ts +16 -0
- package/dist/commands-init.js +254 -93
- package/dist/commands-init.js.map +1 -1
- package/dist/commands-send-support.js +3 -0
- package/dist/commands-send-support.js.map +1 -1
- package/dist/commands-send.d.ts +1 -1
- package/dist/commands-send.js +121 -35
- package/dist/commands-send.js.map +1 -1
- package/dist/commands-skill.js +72 -54
- package/dist/commands-skill.js.map +1 -1
- package/dist/commands-update.d.ts +10 -0
- package/dist/commands-update.js +141 -0
- package/dist/commands-update.js.map +1 -0
- package/dist/commands.d.ts +2 -1
- package/dist/commands.js +2 -1
- package/dist/commands.js.map +1 -1
- package/dist/hook-adapter.d.ts +2 -0
- package/dist/hook-adapter.js +12 -1
- package/dist/hook-adapter.js.map +1 -1
- package/dist/hooks.d.ts +43 -2
- package/dist/hooks.js +163 -21
- package/dist/hooks.js.map +1 -1
- package/dist/invocation-context.d.ts +1 -0
- package/dist/invocation-context.js +9 -1
- package/dist/invocation-context.js.map +1 -1
- package/dist/main-run.d.ts +1 -0
- package/dist/main-run.js +42 -0
- package/dist/main-run.js.map +1 -0
- package/dist/main.js +10 -31
- package/dist/main.js.map +1 -1
- package/dist/native-skills.d.ts +13 -3
- package/dist/native-skills.js +51 -12
- package/dist/native-skills.js.map +1 -1
- package/dist/node-floor.d.ts +19 -0
- package/dist/node-floor.js +39 -0
- package/dist/node-floor.js.map +1 -0
- package/dist/orca-session-title.d.ts +5 -4
- package/dist/orca-session-title.js +32 -19
- package/dist/orca-session-title.js.map +1 -1
- package/dist/platform.d.ts +11 -17
- package/dist/platform.js +20 -23
- package/dist/platform.js.map +1 -1
- package/dist/program.d.ts +2 -1
- package/dist/program.js +22 -5
- package/dist/program.js.map +1 -1
- package/dist/question-settlement-process.d.ts +15 -0
- package/dist/question-settlement-process.js +35 -0
- package/dist/question-settlement-process.js.map +1 -0
- package/dist/readiness.d.ts +25 -1
- package/dist/readiness.js +32 -2
- package/dist/readiness.js.map +1 -1
- package/dist/release.d.ts +5 -5
- package/dist/release.js +5 -5
- package/dist/setup-destinations.d.ts +23 -0
- package/dist/setup-destinations.js +44 -0
- package/dist/setup-destinations.js.map +1 -0
- package/dist/skill-integrity.d.ts +49 -0
- package/dist/skill-integrity.js +154 -0
- package/dist/skill-integrity.js.map +1 -0
- package/dist/skill-source/manifest.json +16 -0
- package/dist/skill-source/notifai/SKILL.md +390 -0
- package/dist/skill-source/notifai/references/harness-setup.md +220 -0
- package/package.json +11 -14
- package/src/atomic-file.ts +0 -198
- package/src/claude-wake.test.ts +0 -321
- package/src/claude-wake.ts +0 -499
- package/src/cli-bin.test.ts +0 -38
- package/src/cli-bin.ts +0 -67
- package/src/cli-release.test.ts +0 -46
- package/src/cli-release.ts +0 -78
- package/src/client.test.ts +0 -390
- package/src/client.ts +0 -345
- package/src/codex-wake.test.ts +0 -254
- package/src/codex-wake.ts +0 -297
- package/src/command-session.ts +0 -38
- package/src/commands-agent-sessions.ts +0 -72
- package/src/commands-auth.ts +0 -205
- package/src/commands-config.ts +0 -336
- package/src/commands-core.ts +0 -298
- package/src/commands-devices.test.ts +0 -15
- package/src/commands-devices.ts +0 -173
- package/src/commands-doctor.ts +0 -1440
- package/src/commands-guidance.ts +0 -181
- package/src/commands-harness-context.ts +0 -207
- package/src/commands-hook-shape.ts +0 -69
- package/src/commands-hooks.ts +0 -2268
- package/src/commands-init.ts +0 -878
- package/src/commands-io.ts +0 -108
- package/src/commands-logs.ts +0 -243
- package/src/commands-project.ts +0 -47
- package/src/commands-send-support.test.ts +0 -156
- package/src/commands-send-support.ts +0 -311
- package/src/commands-send.ts +0 -769
- package/src/commands-setup-proof.test.ts +0 -72
- package/src/commands-setup-proof.ts +0 -124
- package/src/commands-skill.test.ts +0 -44
- package/src/commands-skill.ts +0 -204
- package/src/commands-sounds.test.ts +0 -106
- package/src/commands-sounds.ts +0 -49
- package/src/commands.test.ts +0 -7828
- package/src/commands.ts +0 -75
- package/src/config-schema.test.ts +0 -153
- package/src/config-schema.ts +0 -349
- package/src/config.test.ts +0 -581
- package/src/config.ts +0 -475
- package/src/credentials.test.ts +0 -403
- package/src/credentials.ts +0 -403
- package/src/file-lock-process-worker.test.ts +0 -64
- package/src/file-lock.test.ts +0 -320
- package/src/file-lock.ts +0 -393
- package/src/fixtures/hermes-0.20.6-classic-cli-local.json +0 -50
- package/src/fixtures/openclaw-2026.7.1-2-agent-local.json +0 -57
- package/src/generated-session-label.ts +0 -155
- package/src/guidance-content.ts +0 -278
- package/src/guidance-render.ts +0 -56
- package/src/guidance.test.ts +0 -327
- package/src/guidance.ts +0 -233
- package/src/harness-integration.test.ts +0 -294
- package/src/harnesses.test.ts +0 -149
- package/src/harnesses.ts +0 -164
- package/src/hook-adapter.test.ts +0 -496
- package/src/hook-adapter.ts +0 -410
- package/src/hook-input.test.ts +0 -39
- package/src/hook-input.ts +0 -72
- package/src/hooks.test.ts +0 -5549
- package/src/hooks.ts +0 -3759
- package/src/install-hooks.test.ts +0 -1614
- package/src/install-hooks.ts +0 -1501
- package/src/interactive.test.ts +0 -387
- package/src/interactive.ts +0 -858
- package/src/invocation-context.test.ts +0 -352
- package/src/invocation-context.ts +0 -197
- package/src/logging-process-worker.test.ts +0 -45
- package/src/logging.test.ts +0 -544
- package/src/logging.ts +0 -698
- package/src/main.ts +0 -36
- package/src/native-skills.test.ts +0 -115
- package/src/native-skills.ts +0 -162
- package/src/onboarding-docs.test.ts +0 -25
- package/src/openclaw-plugin.ts +0 -409
- package/src/opencode-plugin.ts +0 -298
- package/src/orca-session-title.test.ts +0 -54
- package/src/orca-session-title.ts +0 -103
- package/src/pack-feedback-slice.test.ts +0 -212
- package/src/pack-feedback-slice.ts +0 -187
- package/src/platform.test.ts +0 -152
- package/src/platform.ts +0 -173
- package/src/program.test.ts +0 -282
- package/src/program.ts +0 -818
- package/src/project-enablement.test.ts +0 -55
- package/src/project-enablement.ts +0 -99
- package/src/question-caps.test.ts +0 -49
- package/src/question-timing.ts +0 -56
- package/src/readiness.test.ts +0 -25
- package/src/readiness.ts +0 -179
- package/src/release.ts +0 -51
- package/src/send-attempts.test.ts +0 -143
- package/src/send-attempts.ts +0 -188
- package/src/send.test.ts +0 -28
- package/src/send.ts +0 -436
- package/src/session-activation.ts +0 -61
- package/src/session-end-log-lock-worker.test.ts +0 -35
- package/src/session-labels.test.ts +0 -490
- package/src/session-labels.ts +0 -474
- package/src/skill.test.ts +0 -415
- package/src/sound-ref.test.ts +0 -31
- package/src/sound-ref.ts +0 -34
- package/src/trust-policy.test.ts +0 -701
- package/src/ui/banner.ts +0 -95
- package/src/ui/config-view.test.ts +0 -90
- package/src/ui/config-view.ts +0 -138
- package/src/ui/help.test.ts +0 -16
- package/src/ui/help.ts +0 -128
- package/src/ui/theme.test.ts +0 -92
- package/src/ui/theme.ts +0 -265
- package/src/url-policy.ts +0 -399
- package/tsconfig.json +0 -10
package/src/send-attempts.ts
DELETED
|
@@ -1,188 +0,0 @@
|
|
|
1
|
-
import { createHash, createHmac, randomBytes } from 'node:crypto'
|
|
2
|
-
import {
|
|
3
|
-
closeSync,
|
|
4
|
-
constants,
|
|
5
|
-
existsSync,
|
|
6
|
-
fstatSync,
|
|
7
|
-
lstatSync,
|
|
8
|
-
openSync,
|
|
9
|
-
readFileSync,
|
|
10
|
-
readdirSync,
|
|
11
|
-
rmSync,
|
|
12
|
-
} from 'node:fs'
|
|
13
|
-
import path from 'node:path'
|
|
14
|
-
import type { NotificationDraftT } from '@raidiant/notifai-protocol'
|
|
15
|
-
import { atomicWriteFileSync } from './atomic-file.js'
|
|
16
|
-
import { stateDir } from './config.js'
|
|
17
|
-
import type { MachineCredential } from './credentials.js'
|
|
18
|
-
|
|
19
|
-
interface SendAttemptRecord {
|
|
20
|
-
version: 1
|
|
21
|
-
attempt_id: string
|
|
22
|
-
fingerprint: string
|
|
23
|
-
idempotency_key: string
|
|
24
|
-
credential_tag: string
|
|
25
|
-
machine_id: string
|
|
26
|
-
service: string
|
|
27
|
-
created_at: string
|
|
28
|
-
expires_at: string
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export const SEND_ATTEMPT_TTL_MS = 24 * 60 * 60 * 1000
|
|
32
|
-
|
|
33
|
-
/** Stable pre-upload media identities used only inside the keyed draft digest. */
|
|
34
|
-
export function semanticMediaIds(images: readonly string[], cwd: string): string[] {
|
|
35
|
-
return images.map((image) => {
|
|
36
|
-
if (image.startsWith('med_')) return image
|
|
37
|
-
const digest = createHash('sha256')
|
|
38
|
-
if (/^https?:\/\//i.test(image)) digest.update(`url\0${image}`)
|
|
39
|
-
else digest.update('file\0').update(readFileSync(path.resolve(cwd, image)))
|
|
40
|
-
return `med_retry_${digest.digest('base64url')}`
|
|
41
|
-
})
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function directory(env: NodeJS.ProcessEnv): string {
|
|
45
|
-
return path.join(stateDir(env), 'send-attempts')
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
function credentialTag(credential: MachineCredential): string {
|
|
49
|
-
return createHmac('sha256', credential.secret)
|
|
50
|
-
.update('notifai-send-attempt-credential-v1')
|
|
51
|
-
.digest('base64url')
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/** A keyed digest proves equality without persisting notification content. */
|
|
55
|
-
export function sendDraftFingerprint(
|
|
56
|
-
draft: NotificationDraftT,
|
|
57
|
-
credential: MachineCredential,
|
|
58
|
-
): string {
|
|
59
|
-
return createHmac('sha256', credential.secret)
|
|
60
|
-
.update('notifai-send-draft-v1\0')
|
|
61
|
-
.update(JSON.stringify(draft))
|
|
62
|
-
.digest('base64url')
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function parseRecord(file: string): SendAttemptRecord | null {
|
|
66
|
-
let descriptor: number | undefined
|
|
67
|
-
try {
|
|
68
|
-
const before = lstatSync(file)
|
|
69
|
-
const uid = typeof process.getuid === 'function' ? process.getuid() : undefined
|
|
70
|
-
if (before.isSymbolicLink() || !before.isFile() || (uid !== undefined && before.uid !== uid)) {
|
|
71
|
-
return null
|
|
72
|
-
}
|
|
73
|
-
const flags = constants.O_RDONLY | (process.platform === 'win32' ? 0 : constants.O_NOFOLLOW)
|
|
74
|
-
descriptor = openSync(file, flags)
|
|
75
|
-
const opened = fstatSync(descriptor)
|
|
76
|
-
if (!opened.isFile() || opened.dev !== before.dev || opened.ino !== before.ino) return null
|
|
77
|
-
const value = JSON.parse(readFileSync(descriptor, 'utf8')) as Partial<SendAttemptRecord>
|
|
78
|
-
return value.version === 1 &&
|
|
79
|
-
typeof value.attempt_id === 'string' &&
|
|
80
|
-
typeof value.fingerprint === 'string' &&
|
|
81
|
-
typeof value.idempotency_key === 'string' &&
|
|
82
|
-
typeof value.credential_tag === 'string' &&
|
|
83
|
-
typeof value.machine_id === 'string' &&
|
|
84
|
-
typeof value.service === 'string' &&
|
|
85
|
-
typeof value.created_at === 'string' &&
|
|
86
|
-
typeof value.expires_at === 'string'
|
|
87
|
-
? value as SendAttemptRecord
|
|
88
|
-
: null
|
|
89
|
-
} catch {
|
|
90
|
-
return null
|
|
91
|
-
} finally {
|
|
92
|
-
if (descriptor !== undefined) closeSync(descriptor)
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
function liveRecords(
|
|
97
|
-
env: NodeJS.ProcessEnv,
|
|
98
|
-
credential: MachineCredential,
|
|
99
|
-
now: number,
|
|
100
|
-
): SendAttemptRecord[] {
|
|
101
|
-
const dir = directory(env)
|
|
102
|
-
if (!existsSync(dir)) return []
|
|
103
|
-
const directoryStat = lstatSync(dir)
|
|
104
|
-
const uid = typeof process.getuid === 'function' ? process.getuid() : undefined
|
|
105
|
-
if (
|
|
106
|
-
directoryStat.isSymbolicLink() ||
|
|
107
|
-
!directoryStat.isDirectory() ||
|
|
108
|
-
(uid !== undefined && directoryStat.uid !== uid)
|
|
109
|
-
) return []
|
|
110
|
-
const tag = credentialTag(credential)
|
|
111
|
-
const records: SendAttemptRecord[] = []
|
|
112
|
-
for (const name of readdirSync(dir)) {
|
|
113
|
-
if (!name.endsWith('.json')) continue
|
|
114
|
-
const file = path.join(dir, name)
|
|
115
|
-
const record = parseRecord(file)
|
|
116
|
-
const expired = record === null || Date.parse(record.expires_at) <= now
|
|
117
|
-
const rotated = record !== null && (
|
|
118
|
-
record.machine_id !== credential.machineId ||
|
|
119
|
-
record.service !== credential.baseUrl ||
|
|
120
|
-
record.credential_tag !== tag
|
|
121
|
-
)
|
|
122
|
-
if (expired || rotated) {
|
|
123
|
-
rmSync(file, { force: true })
|
|
124
|
-
continue
|
|
125
|
-
}
|
|
126
|
-
records.push(record)
|
|
127
|
-
}
|
|
128
|
-
return records
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
export type BeginAttempt =
|
|
132
|
-
| { ok: true; attemptId: string; idempotencyKey: string; replay: boolean }
|
|
133
|
-
| { ok: false; code: 'retry_not_found' | 'retry_ambiguous'; message: string }
|
|
134
|
-
|
|
135
|
-
export function beginSendAttempt(options: {
|
|
136
|
-
env: NodeJS.ProcessEnv
|
|
137
|
-
credential: MachineCredential
|
|
138
|
-
fingerprint: string
|
|
139
|
-
retry: boolean
|
|
140
|
-
idempotencyKey?: string
|
|
141
|
-
now?: number
|
|
142
|
-
}): BeginAttempt {
|
|
143
|
-
const now = options.now ?? Date.now()
|
|
144
|
-
const live = liveRecords(options.env, options.credential, now)
|
|
145
|
-
if (options.retry) {
|
|
146
|
-
const matches = live.filter((record) => record.fingerprint === options.fingerprint)
|
|
147
|
-
if (matches.length === 0) {
|
|
148
|
-
return {
|
|
149
|
-
ok: false,
|
|
150
|
-
code: 'retry_not_found',
|
|
151
|
-
message: 'No unresolved opaque attempt matches this exact send on the current Approved Machine.',
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
if (matches.length > 1) {
|
|
155
|
-
return {
|
|
156
|
-
ok: false,
|
|
157
|
-
code: 'retry_ambiguous',
|
|
158
|
-
message: 'More than one unresolved opaque attempt matches this exact send; refusing to guess or create another notification.',
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
const match = matches[0]!
|
|
162
|
-
return { ok: true, attemptId: match.attempt_id, idempotencyKey: match.idempotency_key, replay: true }
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
const attemptId = `sat_${randomBytes(12).toString('base64url')}`
|
|
166
|
-
const idempotencyKey = options.idempotencyKey ?? `cli-${randomBytes(12).toString('base64url')}`
|
|
167
|
-
const record: SendAttemptRecord = {
|
|
168
|
-
version: 1,
|
|
169
|
-
attempt_id: attemptId,
|
|
170
|
-
fingerprint: options.fingerprint,
|
|
171
|
-
idempotency_key: idempotencyKey,
|
|
172
|
-
credential_tag: credentialTag(options.credential),
|
|
173
|
-
machine_id: options.credential.machineId,
|
|
174
|
-
service: options.credential.baseUrl,
|
|
175
|
-
created_at: new Date(now).toISOString(),
|
|
176
|
-
expires_at: new Date(now + SEND_ATTEMPT_TTL_MS).toISOString(),
|
|
177
|
-
}
|
|
178
|
-
atomicWriteFileSync(
|
|
179
|
-
path.join(directory(options.env), `${attemptId}.json`),
|
|
180
|
-
`${JSON.stringify(record)}\n`,
|
|
181
|
-
{ mode: 0o600, directoryMode: 0o700, requireCurrentUserOwner: true },
|
|
182
|
-
)
|
|
183
|
-
return { ok: true, attemptId, idempotencyKey, replay: false }
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
export function settleSendAttempt(env: NodeJS.ProcessEnv, attemptId: string): void {
|
|
187
|
-
rmSync(path.join(directory(env), `${attemptId}.json`), { force: true })
|
|
188
|
-
}
|
package/src/send.test.ts
DELETED
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest'
|
|
2
|
-
import {
|
|
3
|
-
accidentalEscapedNewlineMessage,
|
|
4
|
-
hasAccidentalEscapedNewlines,
|
|
5
|
-
rejectAccidentalEscapedNewlines,
|
|
6
|
-
} from './send.js'
|
|
7
|
-
|
|
8
|
-
describe('accidental escaped newlines', () => {
|
|
9
|
-
it('catches a shell-quoted backslash-n body', () => {
|
|
10
|
-
expect(hasAccidentalEscapedNewlines('line1\\nline2')).toBe(true)
|
|
11
|
-
expect(rejectAccidentalEscapedNewlines('line1\\nline2', false)).toBe(
|
|
12
|
-
accidentalEscapedNewlineMessage(),
|
|
13
|
-
)
|
|
14
|
-
})
|
|
15
|
-
|
|
16
|
-
it('preserves real multiline Markdown', () => {
|
|
17
|
-
expect(hasAccidentalEscapedNewlines('line1\nline2')).toBe(false)
|
|
18
|
-
expect(rejectAccidentalEscapedNewlines('line1\nline2', false)).toBeNull()
|
|
19
|
-
})
|
|
20
|
-
|
|
21
|
-
it('lets an explicit flag keep visible backslash-n text', () => {
|
|
22
|
-
expect(rejectAccidentalEscapedNewlines('use \\n in the sample', true)).toBeNull()
|
|
23
|
-
})
|
|
24
|
-
|
|
25
|
-
it('does not treat an escaped backslash plus n as accidental', () => {
|
|
26
|
-
expect(hasAccidentalEscapedNewlines('escape as \\\\n')).toBe(false)
|
|
27
|
-
})
|
|
28
|
-
})
|
package/src/send.ts
DELETED
|
@@ -1,436 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
COLLAPSE_KEY_MAX_BYTES,
|
|
3
|
-
INTERRUPTION_LEVELS,
|
|
4
|
-
MEDIA_MAX_ITEMS,
|
|
5
|
-
NOTIFICATION_SCHEMA_VERSION,
|
|
6
|
-
QUESTION_TEXT_MAX_LENGTH,
|
|
7
|
-
bannerExcerpt,
|
|
8
|
-
type AndroidOptionsT,
|
|
9
|
-
type IosOptionsT,
|
|
10
|
-
type LifecycleT,
|
|
11
|
-
type MacosOptionsT,
|
|
12
|
-
type MediaItemT,
|
|
13
|
-
type NotificationDraftT,
|
|
14
|
-
NOTIFICATION_KINDS,
|
|
15
|
-
PLATFORMS,
|
|
16
|
-
type Platform,
|
|
17
|
-
type QuestionT,
|
|
18
|
-
type SourceContextT,
|
|
19
|
-
type SubmissionReceipt,
|
|
20
|
-
} from '@raidiant/notifai-protocol'
|
|
21
|
-
import type { CliConfig } from './config.js'
|
|
22
|
-
import { isCliSoundRef, unknownSoundMessage } from './sound-ref.js'
|
|
23
|
-
|
|
24
|
-
export interface SendFlags {
|
|
25
|
-
title: string
|
|
26
|
-
/** The one canonical Markdown body. */
|
|
27
|
-
body: string
|
|
28
|
-
subtitle?: string
|
|
29
|
-
/** What this notification is; absent means update. */
|
|
30
|
-
kind?: string
|
|
31
|
-
project?: string
|
|
32
|
-
/** Deliberately omit Project identity even when config or cwd could supply one. */
|
|
33
|
-
projectless?: boolean
|
|
34
|
-
/** Opaque exact Agent Session override. */
|
|
35
|
-
sessionId?: string
|
|
36
|
-
/** Human-readable Agent Session label override. */
|
|
37
|
-
sessionLabel?: string
|
|
38
|
-
device?: string[]
|
|
39
|
-
all?: boolean
|
|
40
|
-
ttl?: number
|
|
41
|
-
collapseKey?: string
|
|
42
|
-
sound?: string
|
|
43
|
-
threadId?: string
|
|
44
|
-
/** Apple interruption level; null is reserved for internal callers that must suppress config. */
|
|
45
|
-
level?: string | null
|
|
46
|
-
data?: string[]
|
|
47
|
-
/** Ready media ids in author-supplied order. Commands resolve paths and URLs first. */
|
|
48
|
-
image?: string[]
|
|
49
|
-
/** Optional alt text paired with images by position. */
|
|
50
|
-
imageAlt?: string[]
|
|
51
|
-
/**
|
|
52
|
-
* Complete canonical media manifest for internal callers that already froze
|
|
53
|
-
* uploads and alt text. Mutually exclusive with the CLI's parallel inputs.
|
|
54
|
-
*/
|
|
55
|
-
media?: MediaItemT[]
|
|
56
|
-
/** Enable the inline reply action for this Notification Request. */
|
|
57
|
-
reply?: boolean
|
|
58
|
-
/** How long the server accepts a reply after submission. */
|
|
59
|
-
replyWindow?: number
|
|
60
|
-
/**
|
|
61
|
-
* Answer labels; turns the reply into a closed question. One flag occurrence
|
|
62
|
-
* per label, always — a label is a label, whatever characters it contains.
|
|
63
|
-
*/
|
|
64
|
-
choice?: string[]
|
|
65
|
-
/** The user may select several of the offered answers. */
|
|
66
|
-
multi?: boolean
|
|
67
|
-
/**
|
|
68
|
-
* A full question set, overriding the single question the flags describe.
|
|
69
|
-
* Not a user-facing flag: `ask --form` and the hooks build it.
|
|
70
|
-
*/
|
|
71
|
-
questions?: QuestionT[]
|
|
72
|
-
/** Platform whose optional notification fields these flags configure. */
|
|
73
|
-
platform?: string
|
|
74
|
-
/**
|
|
75
|
-
* Lifecycle position of this send; not a user-facing flag. Hooks set it so
|
|
76
|
-
* questions and their retirements ride the wire as what they are.
|
|
77
|
-
*/
|
|
78
|
-
lifecycle?: LifecycleT
|
|
79
|
-
/**
|
|
80
|
-
* Allow the two-character sequence backslash-n to be sent as visible text.
|
|
81
|
-
* Without this, a likely accidental escaped newline is refused.
|
|
82
|
-
*/
|
|
83
|
-
literalBackslashN?: boolean
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/** A backslash-n pair that was not itself escaped as a literal backslash. */
|
|
87
|
-
const ACCIDENTAL_ESCAPED_NEWLINE = /(?:^|[^\\])\\n/
|
|
88
|
-
|
|
89
|
-
export function hasAccidentalEscapedNewlines(body: string): boolean {
|
|
90
|
-
return ACCIDENTAL_ESCAPED_NEWLINE.test(body)
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
export function accidentalEscapedNewlineMessage(): string {
|
|
94
|
-
return [
|
|
95
|
-
'This body contains the two-character sequence \\n, which is sent literally and shows as \\n on the device.',
|
|
96
|
-
'To send a real line break, pass a multiline --body or --body-file -.',
|
|
97
|
-
'If you meant the visible characters \\n, pass --literal-backslash-n.',
|
|
98
|
-
].join('\n')
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
export function rejectAccidentalEscapedNewlines(
|
|
102
|
-
body: string | undefined,
|
|
103
|
-
allowLiteral: boolean | undefined,
|
|
104
|
-
): string | null {
|
|
105
|
-
if (allowLiteral === true || body === undefined) return null
|
|
106
|
-
return hasAccidentalEscapedNewlines(body) ? accidentalEscapedNewlineMessage() : null
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
export type DraftBuild =
|
|
110
|
-
| { ok: true; draft: NotificationDraftT; platform: Platform }
|
|
111
|
-
| { ok: false; error: string }
|
|
112
|
-
|
|
113
|
-
/** Invocation-derived values beneath explicit and configured author input. */
|
|
114
|
-
export interface DraftInvocation {
|
|
115
|
-
inferredProject?: string | null
|
|
116
|
-
source?: SourceContextT
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
const POSITIONAL_MEDIA_REFERENCE = /media:(\d+)(?![A-Za-z0-9_-])/g
|
|
120
|
-
|
|
121
|
-
/** Validate media cardinality and positional alt pairing before any upload starts. */
|
|
122
|
-
export function validateMediaInputs(
|
|
123
|
-
images: readonly string[] | undefined,
|
|
124
|
-
alts: readonly string[] | undefined,
|
|
125
|
-
): string | null {
|
|
126
|
-
const imageCount = images?.length ?? 0
|
|
127
|
-
const imageAlts = alts ?? []
|
|
128
|
-
if (imageCount > MEDIA_MAX_ITEMS) return `Attach at most ${MEDIA_MAX_ITEMS} images.`
|
|
129
|
-
if (imageAlts.length > imageCount) {
|
|
130
|
-
return `Received ${imageAlts.length} --image-alt values for ${imageCount} --image values.`
|
|
131
|
-
}
|
|
132
|
-
if (imageAlts.some((alt) => alt.length < 1 || alt.length > 256)) {
|
|
133
|
-
return '--image-alt must be 1-256 characters.'
|
|
134
|
-
}
|
|
135
|
-
return null
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/** Validate a frozen value-object manifest without collapsing sparse alt text. */
|
|
139
|
-
function validateMediaManifest(media: readonly MediaItemT[]): string | null {
|
|
140
|
-
if (media.length > MEDIA_MAX_ITEMS) return `Attach at most ${MEDIA_MAX_ITEMS} images.`
|
|
141
|
-
if (media.some((item) => item.alt !== undefined && (item.alt.length < 1 || item.alt.length > 256))) {
|
|
142
|
-
return 'Media alt text must be 1-256 characters.'
|
|
143
|
-
}
|
|
144
|
-
return null
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
/** Rewrite authorable 1-based media positions to canonical ready media ids. */
|
|
148
|
-
export function rewriteMediaReferences(
|
|
149
|
-
body: string,
|
|
150
|
-
mediaIds: readonly string[],
|
|
151
|
-
): { ok: true; body: string } | { ok: false; error: string } {
|
|
152
|
-
let error: string | undefined
|
|
153
|
-
const rewritten = body.replace(POSITIONAL_MEDIA_REFERENCE, (reference, digits: string) => {
|
|
154
|
-
const position = Number(digits)
|
|
155
|
-
if (
|
|
156
|
-
!Number.isSafeInteger(position) ||
|
|
157
|
-
position < 1 ||
|
|
158
|
-
position > MEDIA_MAX_ITEMS ||
|
|
159
|
-
digits !== String(position) ||
|
|
160
|
-
mediaIds[position - 1] === undefined
|
|
161
|
-
) {
|
|
162
|
-
error = `Body reference "${reference}" has no matching --image occurrence.`
|
|
163
|
-
return reference
|
|
164
|
-
}
|
|
165
|
-
return `media:${mediaIds[position - 1]}`
|
|
166
|
-
})
|
|
167
|
-
return error === undefined ? { ok: true, body: rewritten } : { ok: false, error }
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
/** Merge flags over resolved config into a Notification Request draft. */
|
|
171
|
-
export function buildDraft(
|
|
172
|
-
config: CliConfig,
|
|
173
|
-
flags: SendFlags,
|
|
174
|
-
invocation: DraftInvocation = {},
|
|
175
|
-
): DraftBuild {
|
|
176
|
-
if (!flags.title || !flags.body) return { ok: false, error: 'Both --title and --body are required.' }
|
|
177
|
-
const platform = resolvePlatform(flags.platform)
|
|
178
|
-
if (!platform) {
|
|
179
|
-
return { ok: false, error: `Unknown platform "${flags.platform}" — use ${PLATFORMS.join(' or ')}.` }
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
if (flags.media !== undefined && (flags.image !== undefined || flags.imageAlt !== undefined)) {
|
|
183
|
-
return { ok: false, error: 'Internal media manifests cannot be combined with --image or --image-alt.' }
|
|
184
|
-
}
|
|
185
|
-
const imageIds = flags.media?.map((item) => item.media_id) ?? flags.image ?? []
|
|
186
|
-
const imageAlts = flags.imageAlt ?? []
|
|
187
|
-
const mediaInputError =
|
|
188
|
-
flags.media === undefined
|
|
189
|
-
? validateMediaInputs(imageIds, imageAlts)
|
|
190
|
-
: validateMediaManifest(flags.media)
|
|
191
|
-
if (mediaInputError !== null) return { ok: false, error: mediaInputError }
|
|
192
|
-
const media =
|
|
193
|
-
flags.media ??
|
|
194
|
-
imageIds.map((media_id, index) => ({
|
|
195
|
-
media_id,
|
|
196
|
-
...(imageAlts[index] !== undefined ? { alt: imageAlts[index] } : {}),
|
|
197
|
-
}))
|
|
198
|
-
const body = rewriteMediaReferences(flags.body, imageIds)
|
|
199
|
-
if (!body.ok) return body
|
|
200
|
-
|
|
201
|
-
const deviceIds = flags.all ? null : (flags.device?.length ? flags.device : config.devices.value)
|
|
202
|
-
const targets: NotificationDraftT['targets'] =
|
|
203
|
-
deviceIds && deviceIds.length > 0 ? { mode: 'selected', device_ids: deviceIds } : { mode: 'all' }
|
|
204
|
-
|
|
205
|
-
if (
|
|
206
|
-
flags.kind !== undefined &&
|
|
207
|
-
!NOTIFICATION_KINDS.includes(flags.kind as (typeof NOTIFICATION_KINDS)[number])
|
|
208
|
-
) {
|
|
209
|
-
return {
|
|
210
|
-
ok: false,
|
|
211
|
-
error: `Unknown kind "${flags.kind}" — supported: ${NOTIFICATION_KINDS.map((kind) => `"${kind}"`).join(', ')}.`,
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
// Explicit --sound and the saved sound key outrank kind. The server is the
|
|
216
|
-
// one that applies kind, Project, and Account maps — stamping a kind default
|
|
217
|
-
// here would look like an explicit choice and skip those layers. `none` is
|
|
218
|
-
// still silent.
|
|
219
|
-
const sound = flags.sound !== undefined ? flags.sound : config.sound.value
|
|
220
|
-
if (sound !== null && sound !== undefined && !isCliSoundRef(sound)) {
|
|
221
|
-
return {
|
|
222
|
-
ok: false,
|
|
223
|
-
error: unknownSoundMessage(sound),
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
const level =
|
|
227
|
-
flags.level !== undefined ? flags.level : config.interruption_level.value
|
|
228
|
-
if (
|
|
229
|
-
level !== null &&
|
|
230
|
-
level !== undefined &&
|
|
231
|
-
!INTERRUPTION_LEVELS.includes(level as (typeof INTERRUPTION_LEVELS)[number])
|
|
232
|
-
) {
|
|
233
|
-
return { ok: false, error: `Unknown interruption level "${level}".` }
|
|
234
|
-
}
|
|
235
|
-
if (platform === 'android' && level !== null && level !== undefined) {
|
|
236
|
-
return {
|
|
237
|
-
ok: false,
|
|
238
|
-
error:
|
|
239
|
-
'Android does not support caller-selected interruption levels. Remove --level or unset the configured interruption_level; kind, notification channels, and device settings own attention on Android.',
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
const appleOptions: IosOptionsT & MacosOptionsT = {}
|
|
244
|
-
const androidOptions: AndroidOptionsT = {}
|
|
245
|
-
if (sound === 'none') {
|
|
246
|
-
appleOptions.sound = null
|
|
247
|
-
androidOptions.sound = null
|
|
248
|
-
} else if (sound !== null && sound !== undefined) {
|
|
249
|
-
appleOptions.sound = sound
|
|
250
|
-
androidOptions.sound = sound
|
|
251
|
-
}
|
|
252
|
-
if (flags.threadId !== undefined) {
|
|
253
|
-
appleOptions.thread_id = flags.threadId
|
|
254
|
-
androidOptions.thread_id = flags.threadId
|
|
255
|
-
}
|
|
256
|
-
if (level !== null && level !== undefined) {
|
|
257
|
-
appleOptions.interruption_level = level as (typeof INTERRUPTION_LEVELS)[number]
|
|
258
|
-
}
|
|
259
|
-
if (flags.data?.length) {
|
|
260
|
-
const data: Record<string, string> = {}
|
|
261
|
-
for (const pair of flags.data) {
|
|
262
|
-
const eq = pair.indexOf('=')
|
|
263
|
-
if (eq <= 0) return { ok: false, error: `--data expects key=value, got "${pair}".` }
|
|
264
|
-
data[pair.slice(0, eq)] = pair.slice(eq + 1)
|
|
265
|
-
}
|
|
266
|
-
appleOptions.custom_data = data
|
|
267
|
-
androidOptions.custom_data = data
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
const ttl = flags.ttl ?? config.ttl_seconds.value
|
|
271
|
-
const collapse = flags.collapseKey ?? config.collapse_key.value
|
|
272
|
-
if (
|
|
273
|
-
collapse !== null &&
|
|
274
|
-
collapse !== undefined &&
|
|
275
|
-
Buffer.byteLength(collapse, 'utf8') > COLLAPSE_KEY_MAX_BYTES
|
|
276
|
-
) {
|
|
277
|
-
return {
|
|
278
|
-
ok: false,
|
|
279
|
-
error: `--collapse-key must be at most ${COLLAPSE_KEY_MAX_BYTES} UTF-8 bytes.`,
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
if (flags.projectless === true && flags.project !== undefined) {
|
|
284
|
-
return { ok: false, error: 'Choose either --project or --projectless, not both.' }
|
|
285
|
-
}
|
|
286
|
-
const project = flags.projectless === true
|
|
287
|
-
? undefined
|
|
288
|
-
: flags.project ?? config.project.value ?? invocation.inferredProject
|
|
289
|
-
|
|
290
|
-
// Ids are derived from labels so an agent writing a one-liner does not have
|
|
291
|
-
// to invent them, but they stay the stable thing it branches on afterwards.
|
|
292
|
-
const choices = parseChoices(flags.choice)
|
|
293
|
-
if (choices === 'invalid') {
|
|
294
|
-
return {
|
|
295
|
-
ok: false,
|
|
296
|
-
error: CHOICE_USAGE,
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
if (flags.multi && choices === null && flags.questions === undefined) {
|
|
300
|
-
return { ok: false, error: '--multi needs answers to select between; add --choice.' }
|
|
301
|
-
}
|
|
302
|
-
// A single reply question is the first readable block of the canonical body.
|
|
303
|
-
// Context may follow after a blank line without changing what the user answers.
|
|
304
|
-
const derivedQuestion = bannerExcerpt(body.body).split('\n', 1)[0]!.trim()
|
|
305
|
-
if (
|
|
306
|
-
flags.reply &&
|
|
307
|
-
flags.questions === undefined &&
|
|
308
|
-
derivedQuestion.length > QUESTION_TEXT_MAX_LENGTH
|
|
309
|
-
) {
|
|
310
|
-
return {
|
|
311
|
-
ok: false,
|
|
312
|
-
error:
|
|
313
|
-
`Keep the question within ${QUESTION_TEXT_MAX_LENGTH} characters and put ` +
|
|
314
|
-
'the longer context after a blank line — the first paragraph is the question.',
|
|
315
|
-
}
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
const hasAppleOptions = Object.keys(appleOptions).length > 0
|
|
319
|
-
const hasAndroidOptions = Object.keys(androidOptions).length > 0
|
|
320
|
-
let platformOptions: NonNullable<NotificationDraftT['platform']> | undefined
|
|
321
|
-
if (flags.platform === undefined) {
|
|
322
|
-
if (hasAppleOptions || hasAndroidOptions) {
|
|
323
|
-
platformOptions = {
|
|
324
|
-
...(hasAppleOptions ? { ios: appleOptions, macos: appleOptions } : {}),
|
|
325
|
-
...(hasAndroidOptions ? { android: androidOptions } : {}),
|
|
326
|
-
}
|
|
327
|
-
}
|
|
328
|
-
} else if (platform === 'android') {
|
|
329
|
-
if (hasAndroidOptions) platformOptions = { android: androidOptions }
|
|
330
|
-
} else if (hasAppleOptions) {
|
|
331
|
-
platformOptions =
|
|
332
|
-
platform === 'ios' ? { ios: appleOptions } : { macos: appleOptions }
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
const draft: NotificationDraftT = {
|
|
336
|
-
schema_version: NOTIFICATION_SCHEMA_VERSION,
|
|
337
|
-
...(flags.kind !== undefined
|
|
338
|
-
? { kind: flags.kind as (typeof NOTIFICATION_KINDS)[number] }
|
|
339
|
-
: {}),
|
|
340
|
-
...(flags.lifecycle !== undefined ? { lifecycle: flags.lifecycle } : {}),
|
|
341
|
-
...(project !== null && project !== undefined ? { project } : {}),
|
|
342
|
-
...(invocation.source !== undefined ? { source: invocation.source } : {}),
|
|
343
|
-
presentation: {
|
|
344
|
-
title: flags.title,
|
|
345
|
-
body: body.body,
|
|
346
|
-
...(flags.subtitle !== undefined ? { subtitle: flags.subtitle } : {}),
|
|
347
|
-
...(media.length > 0 ? { media } : {}),
|
|
348
|
-
},
|
|
349
|
-
targets,
|
|
350
|
-
delivery: { ttl_seconds: ttl, collapse_key: collapse },
|
|
351
|
-
...(flags.reply
|
|
352
|
-
? {
|
|
353
|
-
reply: {
|
|
354
|
-
expires_in_seconds: flags.replyWindow ?? config.reply_window_seconds.value,
|
|
355
|
-
questions: flags.questions ?? [
|
|
356
|
-
{
|
|
357
|
-
id: 'q1',
|
|
358
|
-
text: derivedQuestion,
|
|
359
|
-
...(choices !== null ? { choices } : {}),
|
|
360
|
-
...(flags.multi ? { multi: true } : {}),
|
|
361
|
-
},
|
|
362
|
-
],
|
|
363
|
-
},
|
|
364
|
-
}
|
|
365
|
-
: {}),
|
|
366
|
-
...(platformOptions !== undefined ? { platform: platformOptions } : {}),
|
|
367
|
-
}
|
|
368
|
-
return { ok: true, draft, platform }
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
function resolvePlatform(value: string | undefined): Platform | null {
|
|
372
|
-
if (value === undefined) return 'ios'
|
|
373
|
-
return (PLATFORMS as readonly string[]).includes(value) ? (value as Platform) : null
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
/** Human one-screen receipt summary; agents use --json instead. */
|
|
377
|
-
export function formatReceipt(receipt: SubmissionReceipt): string {
|
|
378
|
-
const lines: string[] = []
|
|
379
|
-
const overall =
|
|
380
|
-
receipt.overall === 'provider_accepted_all'
|
|
381
|
-
? 'accepted by the push provider for every device'
|
|
382
|
-
: receipt.overall === 'provider_accepted_partial'
|
|
383
|
-
? 'accepted for some devices'
|
|
384
|
-
: receipt.overall === 'provider_rejected_all'
|
|
385
|
-
? 'rejected for every device'
|
|
386
|
-
: 'still in flight'
|
|
387
|
-
lines.push(`request ${receipt.request_id}${receipt.replayed ? ' (replayed)' : ''}: ${overall}`)
|
|
388
|
-
for (const d of receipt.deliveries) {
|
|
389
|
-
const detail = d.provider_reason ? ` (${d.provider_reason})` : ''
|
|
390
|
-
lines.push(` ${d.device_name}: ${d.state}${detail}`)
|
|
391
|
-
}
|
|
392
|
-
lines.push(
|
|
393
|
-
` device receipt is not assessed here; run notifai status ${receipt.request_id} for Companion Receipt evidence`,
|
|
394
|
-
)
|
|
395
|
-
for (const w of receipt.warnings) lines.push(` warning ${w.path}: ${w.message}`)
|
|
396
|
-
return lines.join('\n')
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
/** Stable exit codes: 0 ok/pending, 1 rejected everywhere. */
|
|
400
|
-
export function receiptExitCode(receipt: SubmissionReceipt): number {
|
|
401
|
-
return receipt.overall === 'provider_rejected_all' ? 1 : 0
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
/**
|
|
405
|
-
* One flag occurrence, one answer: `--choice Staging --choice Production` ->
|
|
406
|
-
* `[{id:'staging',label:'Staging'}, ...]`.
|
|
407
|
-
*
|
|
408
|
-
* Commas are ordinary characters. The old single-value comma split produced
|
|
409
|
-
* buttons the agent never intended ("Nothing, I'll review" became two answers)
|
|
410
|
-
* and nothing downstream could detect it, so the delimiter form is gone
|
|
411
|
-
* rather than guarded: a label is a label.
|
|
412
|
-
*/
|
|
413
|
-
export function parseChoices(
|
|
414
|
-
value: string[] | undefined,
|
|
415
|
-
): { id: string; label: string }[] | null | 'invalid' {
|
|
416
|
-
if (value === undefined || value.length === 0) return null
|
|
417
|
-
const labels = value.map((part) => part.trim()).filter((part) => part.length > 0)
|
|
418
|
-
if (labels.length < 2 || labels.length > 6) return 'invalid'
|
|
419
|
-
const choices = labels.map((label) => ({ id: slugify(label), label }))
|
|
420
|
-
if (choices.some((choice) => choice.id === '')) return 'invalid'
|
|
421
|
-
if (new Set(choices.map((choice) => choice.id)).size !== choices.length) return 'invalid'
|
|
422
|
-
return choices
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
/** Message shared by every surface that accepts choice labels. */
|
|
426
|
-
export const CHOICE_USAGE =
|
|
427
|
-
'Offer 2-6 answers, one --choice flag per answer, unique once slugified — ' +
|
|
428
|
-
'e.g. --choice "Yes, ship it" --choice "Not yet".'
|
|
429
|
-
|
|
430
|
-
export function slugify(label: string): string {
|
|
431
|
-
return label
|
|
432
|
-
.toLowerCase()
|
|
433
|
-
.replace(/[^a-z0-9]+/g, '-')
|
|
434
|
-
.replace(/^-+|-+$/g, '')
|
|
435
|
-
.slice(0, 32)
|
|
436
|
-
}
|