@raidiant/notifai 0.2.1 → 0.3.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/dist/commands.d.ts +33 -3
- package/dist/commands.js +167 -18
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.d.ts +62 -0
- package/dist/config-schema.js +274 -0
- package/dist/config-schema.js.map +1 -0
- package/dist/interactive.d.ts +4 -0
- package/dist/interactive.js +604 -0
- package/dist/interactive.js.map +1 -0
- package/dist/main.js +106 -17
- package/dist/main.js.map +1 -1
- package/dist/ui/banner.d.ts +22 -0
- package/dist/ui/banner.js +87 -0
- package/dist/ui/banner.js.map +1 -0
- package/dist/ui/config-view.d.ts +32 -0
- package/dist/ui/config-view.js +105 -0
- package/dist/ui/config-view.js.map +1 -0
- package/dist/ui/help.d.ts +46 -0
- package/dist/ui/help.js +105 -0
- package/dist/ui/help.js.map +1 -0
- package/dist/ui/theme.d.ts +108 -0
- package/dist/ui/theme.js +261 -0
- package/dist/ui/theme.js.map +1 -0
- package/package.json +9 -8
- package/src/commands.test.ts +87 -8
- package/src/commands.ts +202 -17
- package/src/config-schema.test.ts +152 -0
- package/src/config-schema.ts +321 -0
- package/src/interactive.ts +692 -0
- package/src/main.ts +107 -16
- package/src/ui/banner.ts +95 -0
- package/src/ui/config-view.test.ts +86 -0
- package/src/ui/config-view.ts +135 -0
- package/src/ui/help.ts +124 -0
- package/src/ui/theme.test.ts +92 -0
- package/src/ui/theme.ts +279 -0
package/src/commands.test.ts
CHANGED
|
@@ -17,7 +17,9 @@ import {
|
|
|
17
17
|
buildQuestions,
|
|
18
18
|
assessReadiness,
|
|
19
19
|
capabilitiesCommand,
|
|
20
|
+
configExplainCommand,
|
|
20
21
|
configSetCommand,
|
|
22
|
+
configShowCommand,
|
|
21
23
|
contradictingAnswer,
|
|
22
24
|
describeHookFailure,
|
|
23
25
|
doctorCommand,
|
|
@@ -38,6 +40,8 @@ import {
|
|
|
38
40
|
import { applyPlan, buildHookConfig } from './install-hooks.js'
|
|
39
41
|
import { readSessionState, writeProjectSession, writeSessionState } from './hooks.js'
|
|
40
42
|
import type { NativeSkill, NativeSkills, SkillScope } from './native-skills.js'
|
|
43
|
+
import { CONFIG_KEYS } from './config.js'
|
|
44
|
+
import type { Tone } from './ui/theme.js'
|
|
41
45
|
|
|
42
46
|
class CapturedIo implements CommandIo {
|
|
43
47
|
outLines: string[] = []
|
|
@@ -72,7 +76,7 @@ class InteractiveIo extends CapturedIo {
|
|
|
72
76
|
intros: string[] = []
|
|
73
77
|
outros: string[] = []
|
|
74
78
|
spinnerEvents: string[] = []
|
|
75
|
-
checks: { ok: boolean; message: string }[] = []
|
|
79
|
+
checks: { ok: boolean; message: string; tone?: Tone }[] = []
|
|
76
80
|
|
|
77
81
|
override async confirm(question: string) {
|
|
78
82
|
this.prompts.push(question)
|
|
@@ -111,8 +115,8 @@ class InteractiveIo extends CapturedIo {
|
|
|
111
115
|
}
|
|
112
116
|
}
|
|
113
117
|
|
|
114
|
-
async check(ok: boolean, message: string) {
|
|
115
|
-
this.checks.push({ ok, message })
|
|
118
|
+
async check(ok: boolean, message: string, tone?: Tone) {
|
|
119
|
+
this.checks.push({ ok, message, ...(tone === undefined ? {} : { tone }) })
|
|
116
120
|
}
|
|
117
121
|
}
|
|
118
122
|
|
|
@@ -919,6 +923,67 @@ describe('projectSlugFrom', () => {
|
|
|
919
923
|
})
|
|
920
924
|
})
|
|
921
925
|
|
|
926
|
+
describe('config surfaces', () => {
|
|
927
|
+
function configDeps(io: CapturedIo): CommandDeps {
|
|
928
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-config-surface-'))
|
|
929
|
+
return { ...makeDeps(io, {} as ApiClient), cwd, env: { XDG_CONFIG_HOME: path.join(cwd, 'xdg') } }
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
it('gives an agent the same flat key = value output it always had', () => {
|
|
933
|
+
// The whole point of gating the readable rendering on a human terminal.
|
|
934
|
+
// Anything parsing this today must keep parsing it tomorrow.
|
|
935
|
+
const io = new CapturedIo()
|
|
936
|
+
expect(configShowCommand(configDeps(io), {})).toBe(EXIT.ok)
|
|
937
|
+
expect(io.outLines).toHaveLength(CONFIG_KEYS.length)
|
|
938
|
+
expect(io.outLines[0]).toMatch(/^base_url = "/)
|
|
939
|
+
for (const line of io.outLines) expect(line).toMatch(/^[a-z_]+ = /)
|
|
940
|
+
})
|
|
941
|
+
|
|
942
|
+
it('explains each setting once a human is at the terminal', () => {
|
|
943
|
+
const io = new InteractiveIo()
|
|
944
|
+
expect(configShowCommand(configDeps(io), {})).toBe(EXIT.ok)
|
|
945
|
+
const text = io.outLines.join('\n')
|
|
946
|
+
expect(text).toContain('Questions & presence')
|
|
947
|
+
expect(text).toContain('Whether sitting at this keyboard holds a question back')
|
|
948
|
+
})
|
|
949
|
+
|
|
950
|
+
it('keeps --plain available to a human who wants the parseable form', () => {
|
|
951
|
+
const io = new InteractiveIo()
|
|
952
|
+
expect(configShowCommand(configDeps(io), { plain: true })).toBe(EXIT.ok)
|
|
953
|
+
expect(io.outLines).toHaveLength(CONFIG_KEYS.length)
|
|
954
|
+
})
|
|
955
|
+
|
|
956
|
+
it('explains one setting, and says so in JSON when asked', () => {
|
|
957
|
+
const io = new InteractiveIo()
|
|
958
|
+
expect(configExplainCommand(configDeps(io), 'require_idle', { json: true })).toBe(EXIT.ok)
|
|
959
|
+
const parsed = JSON.parse(io.outLines.join('\n')) as Record<string, unknown>
|
|
960
|
+
expect(parsed['key']).toBe('require_idle')
|
|
961
|
+
expect(parsed['accepts']).toBe('true or false')
|
|
962
|
+
expect(parsed['detail']).toContain('question waits while you are using this machine')
|
|
963
|
+
})
|
|
964
|
+
|
|
965
|
+
it('rejects an unknown setting and points at the nearest real one', () => {
|
|
966
|
+
const io = new CapturedIo()
|
|
967
|
+
expect(configExplainCommand(configDeps(io), 'require_idl')).toBe(EXIT.usage)
|
|
968
|
+
expect(io.errLines[0]).toBe('Unknown setting "require_idl".')
|
|
969
|
+
})
|
|
970
|
+
|
|
971
|
+
it('refuses an enum value the sender would later reject', async () => {
|
|
972
|
+
// `config set sound whatever` used to be written straight to disk: the
|
|
973
|
+
// typo only surfaced when a notification failed to carry the sound.
|
|
974
|
+
const io = new CapturedIo()
|
|
975
|
+
expect(await configSetCommand(configDeps(io), 'sound', 'whatever', { yes: true })).toBe(
|
|
976
|
+
EXIT.usage,
|
|
977
|
+
)
|
|
978
|
+
expect(io.errLines[0]).toContain('default, done, attention, alert, none')
|
|
979
|
+
})
|
|
980
|
+
|
|
981
|
+
it('still accepts a legal enum value', async () => {
|
|
982
|
+
const io = new CapturedIo()
|
|
983
|
+
expect(await configSetCommand(configDeps(io), 'sound', 'none', { yes: true })).toBe(EXIT.ok)
|
|
984
|
+
})
|
|
985
|
+
})
|
|
986
|
+
|
|
922
987
|
describe('interactive command UX', () => {
|
|
923
988
|
it('styles login pairing progress for a human terminal', async () => {
|
|
924
989
|
const io = new InteractiveIo()
|
|
@@ -1052,7 +1117,9 @@ describe('interactive command UX', () => {
|
|
|
1052
1117
|
)
|
|
1053
1118
|
expect(io.errLines).toEqual([
|
|
1054
1119
|
'ask_grace_seconds must be between 0 and 540.',
|
|
1055
|
-
|
|
1120
|
+
// Names the key and its range: `"1.5" is not an integer` left the reader
|
|
1121
|
+
// to work out which of the two settings they had just mistyped.
|
|
1122
|
+
'ask_grace_seconds takes a whole number from 0s–540s, not "1.5".',
|
|
1056
1123
|
])
|
|
1057
1124
|
expect(existsSync(configFile)).toBe(false)
|
|
1058
1125
|
})
|
|
@@ -1081,6 +1148,18 @@ describe('interactive command UX', () => {
|
|
|
1081
1148
|
expect(io.checks.some((check) => !check.ok && check.message.startsWith('This machine:'))).toBe(true)
|
|
1082
1149
|
expect(io.checks.some((check) => check.ok && check.message.startsWith('Protocol version:'))).toBe(true)
|
|
1083
1150
|
expect(io.outLines).toEqual([])
|
|
1151
|
+
|
|
1152
|
+
// Four tones, not two. A boolean has to round `optional-gap` and `unknown`
|
|
1153
|
+
// to pass or fail, and rounding them to pass put a tick beside things the
|
|
1154
|
+
// user had declined and beside things nothing had checked.
|
|
1155
|
+
const tone = (prefix: string): Tone | undefined =>
|
|
1156
|
+
io.checks.find((check) => check.message.startsWith(prefix))?.tone
|
|
1157
|
+
expect(tone('This machine:')).toBe('bad')
|
|
1158
|
+
expect(tone('Protocol version:')).toBe('ok')
|
|
1159
|
+
// Never evaluated: the account cannot be checked without a credential.
|
|
1160
|
+
expect(tone('Account:')).toBe('pending')
|
|
1161
|
+
// Legitimately declined rather than broken.
|
|
1162
|
+
expect(tone('Project identity:')).toBe('warn')
|
|
1084
1163
|
})
|
|
1085
1164
|
|
|
1086
1165
|
it('keeps doctor JSON as one machine-readable stdout document', async () => {
|
|
@@ -1181,7 +1260,7 @@ describe('init', () => {
|
|
|
1181
1260
|
source: 'Raidiant-io/notifai',
|
|
1182
1261
|
sourceType: 'github',
|
|
1183
1262
|
sourceUrl: 'https://github.com/Raidiant-io/notifai.git',
|
|
1184
|
-
ref: 'v0.
|
|
1263
|
+
ref: 'v0.3.0',
|
|
1185
1264
|
}
|
|
1186
1265
|
}
|
|
1187
1266
|
|
|
@@ -1284,7 +1363,7 @@ describe('init', () => {
|
|
|
1284
1363
|
})
|
|
1285
1364
|
|
|
1286
1365
|
it('pins the skill installer to the tagged public release syntax', () => {
|
|
1287
|
-
expect(SKILLS_SOURCE).toBe('Raidiant-io/notifai#v0.
|
|
1366
|
+
expect(SKILLS_SOURCE).toBe('Raidiant-io/notifai#v0.3.0')
|
|
1288
1367
|
expect(SKILLS_SOURCE).not.toContain('@v')
|
|
1289
1368
|
})
|
|
1290
1369
|
|
|
@@ -1307,7 +1386,7 @@ describe('init', () => {
|
|
|
1307
1386
|
source: 'Raidiant-io/notifai',
|
|
1308
1387
|
sourceType: 'github',
|
|
1309
1388
|
sourceUrl: 'https://github.com/Raidiant-io/notifai.git',
|
|
1310
|
-
ref: 'v0.
|
|
1389
|
+
ref: 'v0.3.0',
|
|
1311
1390
|
},
|
|
1312
1391
|
],
|
|
1313
1392
|
}),
|
|
@@ -2207,7 +2286,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
2207
2286
|
source: 'Raidiant-io/notifai',
|
|
2208
2287
|
sourceType: 'github',
|
|
2209
2288
|
sourceUrl: 'https://github.com/Raidiant-io/notifai.git',
|
|
2210
|
-
ref: 'v0.
|
|
2289
|
+
ref: 'v0.3.0',
|
|
2211
2290
|
},
|
|
2212
2291
|
]
|
|
2213
2292
|
: [],
|
package/src/commands.ts
CHANGED
|
@@ -41,8 +41,21 @@ import {
|
|
|
41
41
|
type ConfigKey,
|
|
42
42
|
type FlagOverrides,
|
|
43
43
|
} from './config.js'
|
|
44
|
+
import { acceptedValues, configInfo } from './config-schema.js'
|
|
45
|
+
import {
|
|
46
|
+
renderConfigExplain,
|
|
47
|
+
renderConfigList,
|
|
48
|
+
renderConfigPlain,
|
|
49
|
+
} from './ui/config-view.js'
|
|
44
50
|
import type { CredentialStore, MachineCredential } from './credentials.js'
|
|
45
|
-
import {
|
|
51
|
+
import {
|
|
52
|
+
firstBlocker,
|
|
53
|
+
openItems,
|
|
54
|
+
type Readiness,
|
|
55
|
+
type ReadinessState,
|
|
56
|
+
type StateStatus,
|
|
57
|
+
} from './readiness.js'
|
|
58
|
+
import type { Tone } from './ui/theme.js'
|
|
46
59
|
import {
|
|
47
60
|
handleSessionEnd,
|
|
48
61
|
handleStop,
|
|
@@ -113,7 +126,13 @@ export interface CommandIo {
|
|
|
113
126
|
outro?(message: string): Promise<void>
|
|
114
127
|
note?(message: string, title?: string): Promise<void>
|
|
115
128
|
spinner?(message: string): Promise<CommandSpinner | null>
|
|
116
|
-
|
|
129
|
+
/**
|
|
130
|
+
* A report line. `tone` distinguishes the two states that are neither pass
|
|
131
|
+
* nor fail — something optional the user declined, and something that was
|
|
132
|
+
* never evaluated — which a boolean has to round to one or the other.
|
|
133
|
+
* Optional so existing implementations keep type-checking.
|
|
134
|
+
*/
|
|
135
|
+
check?(ok: boolean, message: string, tone?: Tone): Promise<void>
|
|
117
136
|
}
|
|
118
137
|
|
|
119
138
|
export interface CommandSpinner {
|
|
@@ -387,6 +406,32 @@ export async function devicesCommand(deps: CommandDeps, flags: { json?: boolean
|
|
|
387
406
|
}
|
|
388
407
|
}
|
|
389
408
|
|
|
409
|
+
/**
|
|
410
|
+
* Devices as data, for surfaces that render them themselves.
|
|
411
|
+
*
|
|
412
|
+
* `devicesCommand` prints and returns an exit code, which is the right shape
|
|
413
|
+
* for a command and the wrong one for a menu that wants to show names beside
|
|
414
|
+
* checkboxes. Silent on every failure by design: the interactive app already
|
|
415
|
+
* shows the credential and connectivity state on its status card, so a second
|
|
416
|
+
* error line here would be reporting the same fault twice.
|
|
417
|
+
*/
|
|
418
|
+
export async function deviceInventory(deps: CommandDeps): Promise<RoutableDevice[] | null> {
|
|
419
|
+
if (!deps.store.load()) return null
|
|
420
|
+
const config = loadConfig({ cwd: deps.cwd, env: deps.env })
|
|
421
|
+
const authed = authedClient(deps, config)
|
|
422
|
+
if (!authed) return null
|
|
423
|
+
try {
|
|
424
|
+
return (await authed.client.listDevices()).devices
|
|
425
|
+
} catch {
|
|
426
|
+
return null
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** Whether a device is in a state where a notification would actually arrive. */
|
|
431
|
+
export function canDeviceReceive(device: RoutableDevice): boolean {
|
|
432
|
+
return deviceCanReceive(device)
|
|
433
|
+
}
|
|
434
|
+
|
|
390
435
|
export async function capabilitiesCommand(
|
|
391
436
|
deps: CommandDeps,
|
|
392
437
|
flags: { json?: boolean; platform?: Platform },
|
|
@@ -1750,7 +1795,7 @@ function resolveHarness(deps: CommandDeps, requested: string | undefined): Harne
|
|
|
1750
1795
|
|
|
1751
1796
|
export function configShowCommand(
|
|
1752
1797
|
deps: CommandDeps,
|
|
1753
|
-
flags: { json?: boolean; explain?: boolean },
|
|
1798
|
+
flags: { json?: boolean; explain?: boolean; plain?: boolean },
|
|
1754
1799
|
): number {
|
|
1755
1800
|
const config = loadConfig({ cwd: deps.cwd, env: deps.env })
|
|
1756
1801
|
if (flags.json) {
|
|
@@ -1760,14 +1805,111 @@ export function configShowCommand(
|
|
|
1760
1805
|
deps.io.out(JSON.stringify(output, null, 2))
|
|
1761
1806
|
return EXIT.ok
|
|
1762
1807
|
}
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1808
|
+
// Anything that is not a person at a terminal keeps the flat `key = value`
|
|
1809
|
+
// form it has always had. Scripts parse this, and a prettier layout for an
|
|
1810
|
+
// audience that cannot see it would only be a breaking change.
|
|
1811
|
+
if (deps.io.interactive !== true || flags.plain === true) {
|
|
1812
|
+
for (const line of renderConfigPlain(config, flags.explain === true)) deps.io.out(line)
|
|
1813
|
+
return EXIT.ok
|
|
1814
|
+
}
|
|
1815
|
+
for (const line of renderConfigList(config, { showAdvanced: flags.explain === true })) {
|
|
1816
|
+
deps.io.out(line)
|
|
1767
1817
|
}
|
|
1768
1818
|
return EXIT.ok
|
|
1769
1819
|
}
|
|
1770
1820
|
|
|
1821
|
+
/**
|
|
1822
|
+
* One setting, explained in full.
|
|
1823
|
+
*
|
|
1824
|
+
* The gap this closes: `config show` prints `require_idle = true` and there was
|
|
1825
|
+
* nowhere to go from there. Every key already had a careful explanation — in a
|
|
1826
|
+
* TypeScript comment, read by everyone except the person who needed it.
|
|
1827
|
+
*/
|
|
1828
|
+
export function configExplainCommand(
|
|
1829
|
+
deps: CommandDeps,
|
|
1830
|
+
key: string,
|
|
1831
|
+
flags: { json?: boolean } = {},
|
|
1832
|
+
): number {
|
|
1833
|
+
if (!(CONFIG_KEYS as readonly string[]).includes(key)) {
|
|
1834
|
+
deps.io.err(`Unknown setting "${key}".`)
|
|
1835
|
+
deps.io.err(`Valid settings: ${CONFIG_KEYS.join(', ')}`)
|
|
1836
|
+
return EXIT.usage
|
|
1837
|
+
}
|
|
1838
|
+
const configKey = key as ConfigKey
|
|
1839
|
+
const config = loadConfig({ cwd: deps.cwd, env: deps.env })
|
|
1840
|
+
const info = configInfo(configKey)
|
|
1841
|
+
const entry = config[configKey]
|
|
1842
|
+
|
|
1843
|
+
if (flags.json) {
|
|
1844
|
+
deps.io.out(
|
|
1845
|
+
JSON.stringify(
|
|
1846
|
+
{
|
|
1847
|
+
key: configKey,
|
|
1848
|
+
label: info.label,
|
|
1849
|
+
group: info.group,
|
|
1850
|
+
kind: info.kind,
|
|
1851
|
+
summary: info.summary,
|
|
1852
|
+
detail: info.detail,
|
|
1853
|
+
accepts: acceptedValues(configKey),
|
|
1854
|
+
...(info.choices !== undefined ? { choices: info.choices } : {}),
|
|
1855
|
+
value: entry.value,
|
|
1856
|
+
source: entry.source,
|
|
1857
|
+
},
|
|
1858
|
+
null,
|
|
1859
|
+
2,
|
|
1860
|
+
),
|
|
1861
|
+
)
|
|
1862
|
+
return EXIT.ok
|
|
1863
|
+
}
|
|
1864
|
+
|
|
1865
|
+
if (deps.io.interactive !== true) {
|
|
1866
|
+
deps.io.out(`${configKey} = ${JSON.stringify(entry.value)} [${entry.source}]`)
|
|
1867
|
+
deps.io.out(info.detail.replace(/\n\n/g, '\n'))
|
|
1868
|
+
deps.io.out(`accepts: ${acceptedValues(configKey)}`)
|
|
1869
|
+
return EXIT.ok
|
|
1870
|
+
}
|
|
1871
|
+
for (const line of renderConfigExplain(configKey, config)) deps.io.out(line)
|
|
1872
|
+
return EXIT.ok
|
|
1873
|
+
}
|
|
1874
|
+
|
|
1875
|
+
/**
|
|
1876
|
+
* Closest config key by edit distance, or null when nothing is close.
|
|
1877
|
+
*
|
|
1878
|
+
* The threshold matters more than the algorithm: suggesting a key that shares
|
|
1879
|
+
* three letters with the typo sends the reader to the wrong setting with
|
|
1880
|
+
* confidence, which is worse than listing all fourteen and letting them look.
|
|
1881
|
+
*/
|
|
1882
|
+
function nearestKey(input: string): string | null {
|
|
1883
|
+
let best: string | null = null
|
|
1884
|
+
let bestDistance = Number.POSITIVE_INFINITY
|
|
1885
|
+
for (const candidate of CONFIG_KEYS) {
|
|
1886
|
+
const distance = editDistance(input.toLowerCase(), candidate)
|
|
1887
|
+
if (distance < bestDistance) {
|
|
1888
|
+
bestDistance = distance
|
|
1889
|
+
best = candidate
|
|
1890
|
+
}
|
|
1891
|
+
}
|
|
1892
|
+
return best !== null && bestDistance <= Math.max(2, Math.floor(best.length / 3)) ? best : null
|
|
1893
|
+
}
|
|
1894
|
+
|
|
1895
|
+
function editDistance(a: string, b: string): number {
|
|
1896
|
+
const previous = Array.from({ length: b.length + 1 }, (_, i) => i)
|
|
1897
|
+
for (let i = 1; i <= a.length; i += 1) {
|
|
1898
|
+
let diagonal = previous[0]!
|
|
1899
|
+
previous[0] = i
|
|
1900
|
+
for (let j = 1; j <= b.length; j += 1) {
|
|
1901
|
+
const above = previous[j]!
|
|
1902
|
+
previous[j] = Math.min(
|
|
1903
|
+
previous[j]! + 1,
|
|
1904
|
+
previous[j - 1]! + 1,
|
|
1905
|
+
diagonal + (a[i - 1] === b[j - 1] ? 0 : 1),
|
|
1906
|
+
)
|
|
1907
|
+
diagonal = above
|
|
1908
|
+
}
|
|
1909
|
+
}
|
|
1910
|
+
return previous[b.length]!
|
|
1911
|
+
}
|
|
1912
|
+
|
|
1771
1913
|
export async function configSetCommand(
|
|
1772
1914
|
deps: CommandDeps,
|
|
1773
1915
|
key: string,
|
|
@@ -1775,30 +1917,43 @@ export async function configSetCommand(
|
|
|
1775
1917
|
flags: { project?: boolean; local?: boolean; session?: string; yes?: boolean },
|
|
1776
1918
|
): Promise<number> {
|
|
1777
1919
|
if (!(CONFIG_KEYS as readonly string[]).includes(key)) {
|
|
1778
|
-
deps.io.err(`Unknown
|
|
1920
|
+
deps.io.err(`Unknown setting "${key}".`)
|
|
1921
|
+
const near = nearestKey(key)
|
|
1922
|
+
if (near !== null) deps.io.err(`Did you mean "${near}"?`)
|
|
1923
|
+
deps.io.err(`Valid settings: ${CONFIG_KEYS.join(', ')}`)
|
|
1924
|
+
deps.io.err('Run `notifai config explain <key>` to see what one of them does.')
|
|
1779
1925
|
return EXIT.usage
|
|
1780
1926
|
}
|
|
1927
|
+
const configKey = key as ConfigKey
|
|
1928
|
+
const info = configInfo(configKey)
|
|
1781
1929
|
let value: unknown = rawValue
|
|
1782
|
-
if (NUMERIC_CONFIG_KEYS.includes(
|
|
1930
|
+
if (NUMERIC_CONFIG_KEYS.includes(configKey)) {
|
|
1783
1931
|
const numeric = Number(rawValue)
|
|
1784
1932
|
if (!Number.isInteger(numeric)) {
|
|
1785
|
-
deps.io.err(
|
|
1933
|
+
deps.io.err(`${key} takes ${acceptedValues(configKey)}, not "${rawValue}".`)
|
|
1786
1934
|
return EXIT.usage
|
|
1787
1935
|
}
|
|
1788
|
-
const bounds = configBounds(
|
|
1936
|
+
const bounds = configBounds(configKey)
|
|
1789
1937
|
if (bounds !== undefined && (numeric < bounds.min || numeric > bounds.max)) {
|
|
1790
1938
|
deps.io.err(`${key} must be between ${bounds.min} and ${bounds.max}.`)
|
|
1791
1939
|
return EXIT.usage
|
|
1792
1940
|
}
|
|
1793
1941
|
value = numeric
|
|
1794
1942
|
}
|
|
1795
|
-
if (BOOLEAN_CONFIG_KEYS.includes(
|
|
1943
|
+
if (BOOLEAN_CONFIG_KEYS.includes(configKey)) {
|
|
1796
1944
|
if (rawValue !== 'true' && rawValue !== 'false') {
|
|
1797
1945
|
deps.io.err(`${key} is a toggle — pass "true" or "false", not "${rawValue}".`)
|
|
1798
1946
|
return EXIT.usage
|
|
1799
1947
|
}
|
|
1800
1948
|
value = rawValue === 'true'
|
|
1801
1949
|
}
|
|
1950
|
+
// Enum keys were accepted unchecked, so `config set sound whatever` wrote a
|
|
1951
|
+
// value the sender would later reject — a typo that only surfaces at the
|
|
1952
|
+
// moment a notification fails to carry the sound you asked for.
|
|
1953
|
+
if (info.kind === 'enum' && info.choices !== undefined && !info.choices.includes(rawValue)) {
|
|
1954
|
+
deps.io.err(`${key} takes one of: ${info.choices.join(', ')} — not "${rawValue}".`)
|
|
1955
|
+
return EXIT.usage
|
|
1956
|
+
}
|
|
1802
1957
|
if (key === 'devices') value = rawValue.split(',').map((s) => s.trim()).filter(Boolean)
|
|
1803
1958
|
|
|
1804
1959
|
let layer = flags.local ? 'local' : flags.project ? 'project' : 'global'
|
|
@@ -1857,7 +2012,7 @@ export async function configSetCommand(
|
|
|
1857
2012
|
* 1.5.x, `owner/repo@name` selects a skill; a Git ref belongs after `#`.
|
|
1858
2013
|
* Keep this immutable and public because the command is printed to users.
|
|
1859
2014
|
*/
|
|
1860
|
-
export const SKILLS_SOURCE = 'Raidiant-io/notifai#v0.
|
|
2015
|
+
export const SKILLS_SOURCE = 'Raidiant-io/notifai#v0.3.0'
|
|
1861
2016
|
|
|
1862
2017
|
function skillSourceParts(): { source: string; ref: string } | null {
|
|
1863
2018
|
const match = /^([^#]+)#(.+)$/.exec(SKILLS_SOURCE)
|
|
@@ -3012,7 +3167,13 @@ export async function doctorCommand(deps: CommandDeps, flags: { json?: boolean }
|
|
|
3012
3167
|
// costs nothing.
|
|
3013
3168
|
if (deps.io.interactive === true && deps.io.check) {
|
|
3014
3169
|
await deps.io.intro?.('Notifai doctor')
|
|
3015
|
-
for (const s of readiness.states)
|
|
3170
|
+
for (const s of readiness.states) {
|
|
3171
|
+
// Four states, not two. Rendering `optional-gap` and `unknown` as
|
|
3172
|
+
// successes made a green report that had never checked half of what it
|
|
3173
|
+
// listed, and put a tick beside things the reader had deliberately
|
|
3174
|
+
// declined.
|
|
3175
|
+
await deps.io.check(s.status !== 'gap', line(s), doctorTone(s.status))
|
|
3176
|
+
}
|
|
3016
3177
|
await deps.io.outro?.(ok ? 'Everything looks good' : `Start with: ${remedyLine(blocker)}`)
|
|
3017
3178
|
} else {
|
|
3018
3179
|
for (const s of readiness.states) {
|
|
@@ -3031,6 +3192,20 @@ export async function doctorCommand(deps: CommandDeps, flags: { json?: boolean }
|
|
|
3031
3192
|
return ok ? EXIT.ok : EXIT.failed
|
|
3032
3193
|
}
|
|
3033
3194
|
|
|
3195
|
+
/** Readiness status as a report tone. */
|
|
3196
|
+
function doctorTone(status: StateStatus): Tone {
|
|
3197
|
+
switch (status) {
|
|
3198
|
+
case 'ready':
|
|
3199
|
+
return 'ok'
|
|
3200
|
+
case 'gap':
|
|
3201
|
+
return 'bad'
|
|
3202
|
+
case 'optional-gap':
|
|
3203
|
+
return 'warn'
|
|
3204
|
+
case 'unknown':
|
|
3205
|
+
return 'pending'
|
|
3206
|
+
}
|
|
3207
|
+
}
|
|
3208
|
+
|
|
3034
3209
|
/** One line telling the reader what to actually do about a state. */
|
|
3035
3210
|
function remedyLine(state: ReadinessState): string {
|
|
3036
3211
|
const remedy = state.remedy
|
|
@@ -3463,11 +3638,21 @@ export function realIo(env: NodeJS.ProcessEnv = process.env): CommandIo {
|
|
|
3463
3638
|
error: (next) => progress.error(next),
|
|
3464
3639
|
}
|
|
3465
3640
|
},
|
|
3466
|
-
check: async (ok, message) => {
|
|
3641
|
+
check: async (ok, message, tone) => {
|
|
3467
3642
|
if (!interactive()) return
|
|
3468
3643
|
const { log } = await clack()
|
|
3469
|
-
|
|
3470
|
-
|
|
3644
|
+
switch (tone ?? (ok ? 'ok' : 'bad')) {
|
|
3645
|
+
case 'ok':
|
|
3646
|
+
return log.success(message)
|
|
3647
|
+
case 'warn':
|
|
3648
|
+
return log.warn(message)
|
|
3649
|
+
case 'pending':
|
|
3650
|
+
// Never checked. `log.info` reads as neutral, which is the honest
|
|
3651
|
+
// rendering for a state no evidence was gathered about.
|
|
3652
|
+
return log.info(message)
|
|
3653
|
+
case 'bad':
|
|
3654
|
+
return log.error(message)
|
|
3655
|
+
}
|
|
3471
3656
|
},
|
|
3472
3657
|
openUrl: (url) => {
|
|
3473
3658
|
try {
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
BOOLEAN_CONFIG_KEYS,
|
|
4
|
+
CONFIG_KEYS,
|
|
5
|
+
NUMERIC_CONFIG_KEYS,
|
|
6
|
+
configBounds,
|
|
7
|
+
type ConfigKey,
|
|
8
|
+
} from './config.js'
|
|
9
|
+
import {
|
|
10
|
+
CONFIG_GROUPS,
|
|
11
|
+
acceptedValues,
|
|
12
|
+
configInfo,
|
|
13
|
+
configKeysByGroup,
|
|
14
|
+
describeSource,
|
|
15
|
+
formatDuration,
|
|
16
|
+
formatValue,
|
|
17
|
+
} from './config-schema.js'
|
|
18
|
+
|
|
19
|
+
describe('coverage', () => {
|
|
20
|
+
it('describes every configuration key', () => {
|
|
21
|
+
// The failure this prevents is silent: a key added to `config.ts` without
|
|
22
|
+
// an entry here still resolves, still applies, and still appears in
|
|
23
|
+
// `config show` — with no label, no summary and no way for the reader to
|
|
24
|
+
// find out what it does.
|
|
25
|
+
for (const key of CONFIG_KEYS) {
|
|
26
|
+
const info = configInfo(key)
|
|
27
|
+
expect(info.label, key).toBeTruthy()
|
|
28
|
+
expect(info.summary, key).toBeTruthy()
|
|
29
|
+
expect(info.detail, key).toBeTruthy()
|
|
30
|
+
expect(info.detail.length, key).toBeGreaterThan(info.summary.length)
|
|
31
|
+
}
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('files every key under exactly one declared group', () => {
|
|
35
|
+
const grouped = CONFIG_GROUPS.flatMap((group) => configKeysByGroup(group.id).map((info) => info.key))
|
|
36
|
+
expect([...grouped].sort()).toEqual([...CONFIG_KEYS].sort())
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('keeps summaries short enough to sit beside a value', () => {
|
|
40
|
+
for (const key of CONFIG_KEYS) {
|
|
41
|
+
expect(configInfo(key).summary.length, key).toBeLessThanOrEqual(80)
|
|
42
|
+
}
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('agrees with config.ts about which keys are numbers and toggles', () => {
|
|
46
|
+
// Two sources of truth for a key's type would let the prompt offer a text
|
|
47
|
+
// box for something `config set` will only accept as `true` or `false`.
|
|
48
|
+
for (const key of NUMERIC_CONFIG_KEYS) expect(configInfo(key).kind, key).toBe('integer')
|
|
49
|
+
for (const key of BOOLEAN_CONFIG_KEYS) expect(configInfo(key).kind, key).toBe('boolean')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('offers choices for every enum key', () => {
|
|
53
|
+
for (const key of CONFIG_KEYS) {
|
|
54
|
+
const info = configInfo(key)
|
|
55
|
+
if (info.kind !== 'enum') continue
|
|
56
|
+
expect(info.choices, key).toBeDefined()
|
|
57
|
+
expect(info.choices!.length, key).toBeGreaterThan(1)
|
|
58
|
+
for (const choice of info.choices!) expect(info.choiceHints?.[choice], `${key}.${choice}`).toBeTruthy()
|
|
59
|
+
}
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
describe('acceptedValues', () => {
|
|
64
|
+
it('states the range for a bounded number', () => {
|
|
65
|
+
const bounds = configBounds('ask_grace_seconds')!
|
|
66
|
+
expect(acceptedValues('ask_grace_seconds')).toBe(
|
|
67
|
+
`a whole number from ${bounds.min}s–${bounds.max}s`,
|
|
68
|
+
)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('lists the choices for an enum', () => {
|
|
72
|
+
expect(acceptedValues('sound')).toContain('none')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('names both values for a toggle', () => {
|
|
76
|
+
expect(acceptedValues('require_idle')).toBe('true or false')
|
|
77
|
+
})
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
describe('formatValue', () => {
|
|
81
|
+
it('renders toggles as words rather than JSON', () => {
|
|
82
|
+
expect(formatValue('require_idle', true)).toBe('yes')
|
|
83
|
+
expect(formatValue('require_idle', false)).toBe('no')
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('explains what an unset value means instead of printing null', () => {
|
|
87
|
+
// `sound = null` is accurate and tells the reader nothing about what will
|
|
88
|
+
// happen; the whole complaint about the old output in one line.
|
|
89
|
+
expect(formatValue('sound', null)).toBe('each notification uses the sound for its kind')
|
|
90
|
+
expect(formatValue('devices', null)).toBe('every device that can receive')
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('renders durations in units a person reads', () => {
|
|
94
|
+
expect(formatValue('ask_grace_seconds', 300)).toBe('5m')
|
|
95
|
+
expect(formatValue('ttl_seconds', 86400)).toBe('1d')
|
|
96
|
+
expect(formatValue('wait_seconds', 10)).toBe('10s')
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('joins a device list', () => {
|
|
100
|
+
expect(formatValue('devices', ['dev_a', 'dev_b'])).toBe('dev_a, dev_b')
|
|
101
|
+
})
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
describe('formatDuration', () => {
|
|
105
|
+
it('keeps exact values readable across scales', () => {
|
|
106
|
+
expect(formatDuration(0, 's')).toBe('0s (immediately)')
|
|
107
|
+
expect(formatDuration(45, 's')).toBe('45s')
|
|
108
|
+
expect(formatDuration(120, 's')).toBe('2m')
|
|
109
|
+
expect(formatDuration(3600, 's')).toBe('1h')
|
|
110
|
+
expect(formatDuration(90, 's')).toBe('1m 30s')
|
|
111
|
+
})
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
describe('describeSource', () => {
|
|
115
|
+
it('turns a tagged path into words plus the file', () => {
|
|
116
|
+
expect(describeSource('default')).toEqual({ label: 'default', path: null })
|
|
117
|
+
expect(describeSource('global:/home/me/.config/notifai/config.toml')).toEqual({
|
|
118
|
+
label: 'this machine',
|
|
119
|
+
path: '/home/me/.config/notifai/config.toml',
|
|
120
|
+
})
|
|
121
|
+
expect(describeSource('project:/repo/.notifai/config.toml').label).toBe('this project (shared)')
|
|
122
|
+
expect(describeSource('project-local:/repo/.notifai/config.local.toml').label).toBe(
|
|
123
|
+
'this project (personal)',
|
|
124
|
+
)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('does not invent a label for an unrecognised layer', () => {
|
|
128
|
+
expect(describeSource('future-layer:/tmp/x').label).toBe('future-layer')
|
|
129
|
+
})
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
describe('examples', () => {
|
|
133
|
+
it('suggests a value `config set` would actually accept', () => {
|
|
134
|
+
// The explain screen prints `notifai config set <key> <example>` as a
|
|
135
|
+
// copyable line, so an example that fails validation is worse than none.
|
|
136
|
+
for (const key of CONFIG_KEYS as readonly ConfigKey[]) {
|
|
137
|
+
const info = configInfo(key)
|
|
138
|
+
if (info.example === undefined) continue
|
|
139
|
+
if (info.kind === 'integer') {
|
|
140
|
+
const bounds = configBounds(key)
|
|
141
|
+
const parsed = Number(info.example)
|
|
142
|
+
expect(Number.isInteger(parsed), key).toBe(true)
|
|
143
|
+
if (bounds) {
|
|
144
|
+
expect(parsed, key).toBeGreaterThanOrEqual(bounds.min)
|
|
145
|
+
expect(parsed, key).toBeLessThanOrEqual(bounds.max)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (info.kind === 'boolean') expect(['true', 'false']).toContain(info.example)
|
|
149
|
+
if (info.kind === 'enum') expect(info.choices).toContain(info.example)
|
|
150
|
+
}
|
|
151
|
+
})
|
|
152
|
+
})
|