@raidiant/notifai 10.0.0 → 10.1.1
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 +31 -0
- package/README.md +4 -1
- 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 +32 -14
- package/dist/commands-auth.js.map +1 -1
- package/dist/commands-core.d.ts +10 -1
- package/dist/commands-core.js +13 -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 +158 -71
- package/dist/commands-doctor.js.map +1 -1
- package/dist/commands-harness-context.js +8 -0
- package/dist/commands-harness-context.js.map +1 -1
- package/dist/commands-hooks.js +169 -15
- package/dist/commands-hooks.js.map +1 -1
- package/dist/commands-init.d.ts +16 -0
- package/dist/commands-init.js +254 -91
- package/dist/commands-init.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-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/harnesses.d.ts +2 -2
- package/dist/harnesses.js +22 -2
- package/dist/harnesses.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/install-hooks.js +15 -9
- package/dist/install-hooks.js.map +1 -1
- package/dist/interactive.js +20 -23
- package/dist/interactive.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/node-floor.d.ts +18 -0
- package/dist/node-floor.js +35 -0
- package/dist/node-floor.js.map +1 -0
- package/dist/openclaw-plugin.d.ts +49 -0
- package/dist/openclaw-plugin.js +350 -0
- package/dist/openclaw-plugin.js.map +1 -0
- package/dist/program.d.ts +2 -1
- package/dist/program.js +31 -13
- 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/session-activation.js +1 -1
- package/dist/session-activation.js.map +1 -1
- 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/ui/help.d.ts +4 -5
- package/dist/ui/help.js +5 -6
- package/dist/ui/help.js.map +1 -1
- package/package.json +2 -2
- package/src/cli-bin.test.ts +145 -7
- package/src/cli-bin.ts +176 -10
- package/src/cli-update-contract.ts +9 -0
- package/src/cli-update.test.ts +125 -0
- package/src/client.ts +7 -0
- package/src/codex-wake.ts +5 -1
- package/src/commands-auth.ts +47 -13
- package/src/commands-core.ts +21 -4
- package/src/commands-devices.ts +23 -16
- package/src/commands-doctor.ts +197 -75
- package/src/commands-harness-context.ts +8 -0
- package/src/commands-hooks.ts +210 -18
- package/src/commands-init.ts +275 -92
- package/src/commands-send.ts +145 -39
- package/src/commands-update.ts +180 -0
- package/src/commands.test.ts +701 -53
- package/src/commands.ts +2 -0
- package/src/fixtures/openclaw-2026.7.1-2-agent-local.json +57 -0
- package/src/harness-integration.test.ts +82 -0
- package/src/harnesses.test.ts +9 -1
- package/src/harnesses.ts +24 -2
- package/src/hook-adapter.ts +14 -0
- package/src/hooks.test.ts +275 -0
- package/src/hooks.ts +270 -18
- package/src/install-hooks.test.ts +208 -0
- package/src/install-hooks.ts +19 -9
- package/src/interactive.test.ts +31 -0
- package/src/interactive.ts +20 -26
- package/src/main-run.ts +45 -0
- package/src/main.ts +9 -33
- package/src/node-floor.test.ts +27 -0
- package/src/node-floor.ts +37 -0
- package/src/openclaw-plugin.ts +409 -0
- package/src/program.test.ts +35 -0
- package/src/program.ts +37 -12
- package/src/question-settlement-process.ts +47 -0
- package/src/readiness.ts +35 -2
- package/src/session-activation.ts +1 -1
- package/src/setup-destinations.test.ts +41 -0
- package/src/setup-destinations.ts +50 -0
- package/src/skill.test.ts +13 -2
- package/src/ui/help.test.ts +16 -0
- package/src/ui/help.ts +6 -7
- package/src/commands-devices.test.ts +0 -15
package/src/commands-init.ts
CHANGED
|
@@ -14,7 +14,9 @@ import { personalProjectConfigPath, type CliConfig } from './config.js'
|
|
|
14
14
|
import { projectSlugFrom as inferredProjectSlugFrom } from './invocation-context.js'
|
|
15
15
|
import { SKILLS_INSTALLER_SPEC, type SkillScope } from './native-skills.js'
|
|
16
16
|
import {
|
|
17
|
-
|
|
17
|
+
firstRequiredBlocker,
|
|
18
|
+
isOptionalAutomation,
|
|
19
|
+
isOptionalSetup,
|
|
18
20
|
questionRoutingReady,
|
|
19
21
|
readinessJson,
|
|
20
22
|
type Readiness,
|
|
@@ -25,13 +27,19 @@ import { buildDraft, formatReceipt } from './send.js'
|
|
|
25
27
|
import { loginCommand } from './commands-auth.js'
|
|
26
28
|
import {
|
|
27
29
|
EXIT,
|
|
28
|
-
|
|
30
|
+
SETUP_COMMAND,
|
|
29
31
|
authedClient,
|
|
30
32
|
loadLoggedConfig,
|
|
31
33
|
reportError,
|
|
34
|
+
updateCliCommand,
|
|
32
35
|
type CommandDeps,
|
|
33
36
|
} from './commands-core.js'
|
|
34
|
-
import { readyCompanionDevices
|
|
37
|
+
import { readyCompanionDevices } from './commands-devices.js'
|
|
38
|
+
import {
|
|
39
|
+
companionPlatformLabel,
|
|
40
|
+
setupCompanionUrl,
|
|
41
|
+
type CompanionPlatform,
|
|
42
|
+
} from './setup-destinations.js'
|
|
35
43
|
import { assessReadiness, remedyLine } from './commands-doctor.js'
|
|
36
44
|
import { hooksInstallCommand, pickHarnessesToInstall } from './commands-hooks.js'
|
|
37
45
|
import {
|
|
@@ -44,6 +52,8 @@ import {
|
|
|
44
52
|
} from './commands-setup-proof.js'
|
|
45
53
|
import { listScopedNotifaiSkills, SKILLS_SOURCE } from './commands-skill.js'
|
|
46
54
|
import { enableProject, projectBinding } from './project-enablement.js'
|
|
55
|
+
import { inspectCliInstallations } from './cli-bin.js'
|
|
56
|
+
import { installHookAdapter } from './hook-adapter.js'
|
|
47
57
|
|
|
48
58
|
// ---------------------------------------------------------------------------
|
|
49
59
|
// init
|
|
@@ -124,7 +134,10 @@ async function closeGap(
|
|
|
124
134
|
flags: InitFlags,
|
|
125
135
|
): Promise<GapCloseResult> {
|
|
126
136
|
if (state.id === 'project') {
|
|
127
|
-
|
|
137
|
+
// Naming this checkout is local bookkeeping, not skill or hook placement,
|
|
138
|
+
// so it does not wait on the scope question. Without an explicit answer the
|
|
139
|
+
// name lands in the checkout it describes, which is the answer a User who
|
|
140
|
+
// never sees the question would have given.
|
|
128
141
|
const slug = projectSlugFrom(flags.projectId ?? path.basename(deps.cwd))
|
|
129
142
|
const configPath =
|
|
130
143
|
flags.setupScope === 'global'
|
|
@@ -159,6 +172,25 @@ async function closeGap(
|
|
|
159
172
|
return ok ? 'closed' : 'failed'
|
|
160
173
|
}
|
|
161
174
|
|
|
175
|
+
if (state.id === 'hooks-adapter') {
|
|
176
|
+
const effective = inspectCliInstallations(
|
|
177
|
+
deps.env,
|
|
178
|
+
deps.hookPlatform ?? process.platform,
|
|
179
|
+
).effective
|
|
180
|
+
if (effective === null || effective.artifact_path === null) return 'failed'
|
|
181
|
+
try {
|
|
182
|
+
installHookAdapter(
|
|
183
|
+
{ execPath: process.execPath, scriptPath: effective.artifact_path },
|
|
184
|
+
deps.hookAdapterHome,
|
|
185
|
+
deps.hookPlatform ?? process.platform,
|
|
186
|
+
)
|
|
187
|
+
return 'closed'
|
|
188
|
+
} catch (err) {
|
|
189
|
+
deps.io.err(`Could not retarget Question Routing: ${String(err)}`)
|
|
190
|
+
return 'failed'
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
162
194
|
if (state.id === 'skill') {
|
|
163
195
|
if (deps.nativeSkills === undefined) {
|
|
164
196
|
deps.io.err('Skill installation failed — the native `npx skills` flow is unavailable.')
|
|
@@ -231,12 +263,44 @@ function deviceBridgeMessage(devices: readonly RoutableDevice[]): string {
|
|
|
231
263
|
return 'Waiting for a Companion device to become ready…'
|
|
232
264
|
}
|
|
233
265
|
|
|
266
|
+
/** How many Companion Apps this Account has registered, on any platform. */
|
|
267
|
+
async function registeredCompanions(client: ApiClient): Promise<number> {
|
|
268
|
+
try {
|
|
269
|
+
const response = await client.listDevices()
|
|
270
|
+
return response.devices.filter(
|
|
271
|
+
(device) => device.platform === 'ios' || device.platform === 'android',
|
|
272
|
+
).length
|
|
273
|
+
} catch {
|
|
274
|
+
// Unreachable here means unreachable for the wait that follows; ask, so a
|
|
275
|
+
// User with nothing installed still gets the platform-specific steps.
|
|
276
|
+
return 0
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Which phone the notifications should arrive on.
|
|
282
|
+
*
|
|
283
|
+
* Asked in the User's words, here, so the destination this terminal opens is
|
|
284
|
+
* already about their phone rather than an index they have to choose from
|
|
285
|
+
* again on the other screen. A run with no human gets nothing — it opens no
|
|
286
|
+
* browser and answers no question — so there is no prompt to hang on.
|
|
287
|
+
*/
|
|
288
|
+
async function askCompanionPlatform(deps: CommandDeps): Promise<CompanionPlatform | null> {
|
|
289
|
+
if (deps.io.interactive !== true || deps.io.select === undefined) return null
|
|
290
|
+
const selected = await deps.io.select('Where do you want to receive notifications?', [
|
|
291
|
+
{ value: 'iphone', label: 'iPhone' },
|
|
292
|
+
{ value: 'android', label: 'Android' },
|
|
293
|
+
])
|
|
294
|
+
return selected === 'iphone' || selected === 'android' ? selected : null
|
|
295
|
+
}
|
|
296
|
+
|
|
234
297
|
/**
|
|
235
298
|
* Observe the supported Device Installation path while the user finishes the
|
|
236
|
-
* app-side work. The
|
|
237
|
-
*
|
|
238
|
-
*
|
|
239
|
-
* never wait on
|
|
299
|
+
* app-side work. The bridge is one focused setup destination for the platform
|
|
300
|
+
* they just named — not the omnibus help page, whose own next step is to go
|
|
301
|
+
* somewhere else. Interactive runs offer to open it so the user never types
|
|
302
|
+
* the URL; non-interactive/agent paths only print plain text and never wait on
|
|
303
|
+
* a prompt.
|
|
240
304
|
*
|
|
241
305
|
* The wait budget is stated up front. On expiry we say the *timer* expired —
|
|
242
306
|
* not the setup — and offer another budget when a human is present. Agents
|
|
@@ -255,26 +319,36 @@ async function waitForReadyDevice(deps: CommandDeps, state: ReadinessState): Pro
|
|
|
255
319
|
}
|
|
256
320
|
|
|
257
321
|
const budgetLabel = formatWaitBudget(DEVICE_BRIDGE_TIMEOUT_MS)
|
|
258
|
-
|
|
322
|
+
// A phone that is registered but silent needs its permission allowed, not
|
|
323
|
+
// install steps — and asking which phone would be asking about one they are
|
|
324
|
+
// already holding. Only a User with nothing registered has a platform still
|
|
325
|
+
// to name, and that is read from the account rather than from the wording of
|
|
326
|
+
// the state that got us here.
|
|
327
|
+
const platform = (await registeredCompanions(authed.client)) === 0
|
|
328
|
+
? await askCompanionPlatform(deps)
|
|
329
|
+
: null
|
|
330
|
+
const setupUrl = setupCompanionUrl(authed.baseUrl, platform ?? undefined)
|
|
331
|
+
const stepsLabel =
|
|
332
|
+
platform === null ? 'Setup steps (no typing)' : `${companionPlatformLabel(platform)} steps (no typing)`
|
|
259
333
|
await deps.io.note?.(
|
|
260
334
|
[
|
|
261
335
|
state.detail,
|
|
262
336
|
remedy.summary,
|
|
263
|
-
|
|
264
|
-
`I will wait up to ${budgetLabel} for a Companion
|
|
337
|
+
`${stepsLabel}: ${setupUrl}`,
|
|
338
|
+
`I will wait up to ${budgetLabel} for a Companion App to become ready.`,
|
|
265
339
|
].join('\n'),
|
|
266
|
-
'Finish setup on your
|
|
340
|
+
'Finish setup on your phone',
|
|
267
341
|
)
|
|
268
342
|
|
|
269
|
-
// Open the
|
|
270
|
-
//
|
|
271
|
-
if (await deps.io.confirm('Open
|
|
272
|
-
deps.io.openUrl(
|
|
343
|
+
// Open the destination so the user never has to type the URL. Decline is
|
|
344
|
+
// fine — the URL remains in the note and in the Next: line if they leave.
|
|
345
|
+
if (await deps.io.confirm('Open those steps in your browser?', true)) {
|
|
346
|
+
deps.io.openUrl(setupUrl)
|
|
273
347
|
}
|
|
274
348
|
|
|
275
|
-
if (!(await deps.io.confirm('Wait here while you finish that on your
|
|
349
|
+
if (!(await deps.io.confirm('Wait here while you finish that on your phone?', true))) {
|
|
276
350
|
deps.io.out(
|
|
277
|
-
`OK — finish
|
|
351
|
+
`OK — finish that when you can (steps: ${setupUrl}), then re-run \`notifai init\`.`,
|
|
278
352
|
)
|
|
279
353
|
return 'pending'
|
|
280
354
|
}
|
|
@@ -323,7 +397,7 @@ async function waitForReadyDevice(deps: CommandDeps, state: ReadinessState): Pro
|
|
|
323
397
|
)
|
|
324
398
|
deps.io.err(deviceBridgeMessage(lastDevices).replace(/…$/, '.'))
|
|
325
399
|
deps.io.err(
|
|
326
|
-
`Re-run \`notifai init\` later and it will pick up from here (
|
|
400
|
+
`Re-run \`notifai init\` later and it will pick up from here (steps: ${setupUrl}).`,
|
|
327
401
|
)
|
|
328
402
|
|
|
329
403
|
const keepWaiting = await deps.io.confirm(
|
|
@@ -336,29 +410,47 @@ async function waitForReadyDevice(deps: CommandDeps, state: ReadinessState): Pro
|
|
|
336
410
|
)
|
|
337
411
|
return 'pending'
|
|
338
412
|
}
|
|
339
|
-
spinner?.message(`Waiting another ${budgetLabel} for a Companion
|
|
413
|
+
spinner?.message(`Waiting another ${budgetLabel} for a Companion App…`)
|
|
340
414
|
deadline = now() + DEVICE_BRIDGE_TIMEOUT_MS
|
|
341
415
|
}
|
|
342
416
|
}
|
|
343
417
|
|
|
418
|
+
/**
|
|
419
|
+
* The first notification a User ever receives from Notifai.
|
|
420
|
+
*
|
|
421
|
+
* It is the payoff of the whole setup, and it used to be written as an
|
|
422
|
+
* internal receipt: "This real notification completed setup verification" — a
|
|
423
|
+
* sentence about the check rather than about them — delivered silently, at
|
|
424
|
+
* `passive`, with the sound turned off. So the one arrival that proves their
|
|
425
|
+
* agents can reach them landed in Notification Center with no banner and no
|
|
426
|
+
* sound, where a first-time User has no reason to look and every reason to
|
|
427
|
+
* conclude that nothing came.
|
|
428
|
+
*
|
|
429
|
+
* It now says what is true for them and arrives the way their notifications
|
|
430
|
+
* will. `done` is the honest kind — a body of work finished successfully — and
|
|
431
|
+
* it is also what lets the server pick the sound through its own kind, Project
|
|
432
|
+
* and Account layers instead of this command stamping one and skipping them.
|
|
433
|
+
*/
|
|
344
434
|
function setupProofDraft(
|
|
345
435
|
config: CliConfig,
|
|
346
436
|
device: RoutableDevice,
|
|
347
437
|
): ReturnType<typeof buildDraft> {
|
|
348
438
|
const project = config.project.value
|
|
349
439
|
return buildDraft(config, {
|
|
350
|
-
title: '
|
|
440
|
+
title: 'Your agents can reach you',
|
|
351
441
|
body:
|
|
352
442
|
project === null
|
|
353
|
-
? '
|
|
354
|
-
: `
|
|
355
|
-
kind: '
|
|
443
|
+
? 'Notifai setup is finished. Notifications from your agents will arrive like this one.'
|
|
444
|
+
: `Notifai setup is finished for ${project}. Notifications from your agents will arrive like this one.`,
|
|
445
|
+
kind: 'done',
|
|
356
446
|
platform: device.platform,
|
|
357
447
|
device: [device.device_id],
|
|
358
|
-
|
|
448
|
+
// Noticeable, not intrusive: a normal banner and sound. Deliberately not
|
|
449
|
+
// `time_sensitive` — a setup confirmation has no business breaking a Focus.
|
|
450
|
+
//
|
|
359
451
|
// Android has no caller-selected interruption level. Explicit null keeps an
|
|
360
452
|
// Apple preference in config from leaking into this Android-only proof.
|
|
361
|
-
level: device.platform === 'ios' ? '
|
|
453
|
+
level: device.platform === 'ios' ? 'active' : null,
|
|
362
454
|
collapseKey: 'notifai-setup-verification',
|
|
363
455
|
})
|
|
364
456
|
}
|
|
@@ -543,7 +635,7 @@ async function runSetupProof(deps: CommandDeps): Promise<GapCloseResult> {
|
|
|
543
635
|
|
|
544
636
|
/** Closing a local gap cannot have changed the service, the keychain, or devices. */
|
|
545
637
|
function refreshAfterClose(id: string): readonly ReadinessRefresh[] | undefined {
|
|
546
|
-
return id === 'project' || id === 'project-enablement' || id === 'hooks' || id === 'skill' || id === 'question-routing-settings'
|
|
638
|
+
return id === 'project' || id === 'project-enablement' || id === 'hooks' || id.startsWith('hooks-') || id === 'skill' || id === 'question-routing-settings'
|
|
547
639
|
? ['local']
|
|
548
640
|
: undefined
|
|
549
641
|
}
|
|
@@ -558,7 +650,11 @@ function wantsOptional(deps: CommandDeps, state: ReadinessState, flags: InitFlag
|
|
|
558
650
|
// asked about, for a human and an agent alike.
|
|
559
651
|
if (state.id === 'project') return Promise.resolve(true)
|
|
560
652
|
if (state.id === 'project-enablement') return Promise.resolve(true)
|
|
561
|
-
|
|
653
|
+
// Every other hook sub-state is a report line about routing, not an errand
|
|
654
|
+
// with a yes/no question of its own; offering the hooks question for one of
|
|
655
|
+
// them would ask about something the answer does not change.
|
|
656
|
+
if (state.id !== 'hooks' && state.id !== 'skill') return Promise.resolve(false)
|
|
657
|
+
const explicit = state.id === 'hooks' ? flags.hooks : flags.skills
|
|
562
658
|
if (explicit !== undefined) return Promise.resolve(explicit)
|
|
563
659
|
// An agent is never asked, and never assumed into a change it did not
|
|
564
660
|
// request: silence means no, and the summary says what was skipped.
|
|
@@ -570,30 +666,21 @@ function wantsOptional(deps: CommandDeps, state: ReadinessState, flags: InitFlag
|
|
|
570
666
|
return deps.io.confirm(question, true)
|
|
571
667
|
}
|
|
572
668
|
|
|
573
|
-
/**
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
* output here: someone handed five things to do does none of them, and the
|
|
579
|
-
* order was the script's rather than the dependency graph's — it offered to
|
|
580
|
-
* install hooks after a sign-in that had just failed.
|
|
581
|
-
*
|
|
582
|
-
* So this closes what it can, then surfaces exactly one thing, the first that
|
|
583
|
-
* stands in the way. Re-running advances by one. Idempotence stops being a
|
|
584
|
-
* property to preserve and becomes the mechanism: every decision is derived
|
|
585
|
-
* from observed state, so a partial run, a second project, a fresh worktree
|
|
586
|
-
* and a revoked credential are the same code path arriving at different
|
|
587
|
-
* states rather than four branches to enumerate.
|
|
588
|
-
*/
|
|
669
|
+
/** The two states whose remedy actually places files the scope question is about. */
|
|
670
|
+
function needsInstallScope(state: ReadinessState): boolean {
|
|
671
|
+
return state.id === 'hooks' || state.id === 'skill'
|
|
672
|
+
}
|
|
673
|
+
|
|
589
674
|
function isSetupScope(value: string | undefined): value is SetupScope {
|
|
590
675
|
return value === 'project' || value === 'global'
|
|
591
676
|
}
|
|
592
677
|
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
678
|
+
/**
|
|
679
|
+
* Scope flags are checked before anything runs, because a contradiction is a
|
|
680
|
+
* usage error and reporting it after half a setup is worse than reporting it
|
|
681
|
+
* now. Answering the question is a separate step (`scopeForLocalInstall`).
|
|
682
|
+
*/
|
|
683
|
+
function checkSetupScopeFlags(deps: CommandDeps, flags: InitFlags): SetupScope | undefined | 'usage' {
|
|
597
684
|
if (flags.setupScope !== undefined && !isSetupScope(flags.setupScope)) {
|
|
598
685
|
deps.io.err('Invalid setup scope. Choose `project` or `global`.')
|
|
599
686
|
return 'usage'
|
|
@@ -614,9 +701,42 @@ async function resolveSetupScope(
|
|
|
614
701
|
deps.io.err('`--skills-scope` requires `--skills`. Use `--setup-scope` to choose project or machine for the whole setup.')
|
|
615
702
|
return 'usage'
|
|
616
703
|
}
|
|
704
|
+
const fromFlags = flags.setupScope ?? flags.skillsScope
|
|
705
|
+
// Asking for an install nobody can be asked about is a usage error, and it
|
|
706
|
+
// is knowable from the flags alone — so it is reported before a setup runs
|
|
707
|
+
// half way rather than after.
|
|
708
|
+
if (
|
|
709
|
+
fromFlags === undefined &&
|
|
710
|
+
deps.io.interactive !== true &&
|
|
711
|
+
(flags.skills === true || flags.hooks === true)
|
|
712
|
+
) {
|
|
713
|
+
deps.io.err(
|
|
714
|
+
'Unattended setup changes require an explicit scope: pass `--setup-scope project` or `--setup-scope global`.',
|
|
715
|
+
)
|
|
716
|
+
return 'usage'
|
|
717
|
+
}
|
|
718
|
+
return fromFlags
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/**
|
|
722
|
+
* Where a skill or hook install should land, asked at the moment one is about
|
|
723
|
+
* to happen and not before.
|
|
724
|
+
*
|
|
725
|
+
* It used to be the first thing the product ever said, in front of the
|
|
726
|
+
* assessment, sign-in and every other gate — a question about skill, hook and
|
|
727
|
+
* shared-config placement put to someone who had met none of those words and
|
|
728
|
+
* might never reach the step it decides. Worse, project identity and Project
|
|
729
|
+
* Enablement were gated on the answer, so an unattended run that could not
|
|
730
|
+
* produce one advanced nothing at all.
|
|
731
|
+
*
|
|
732
|
+
* Now nothing depends on it except the two installs it actually describes.
|
|
733
|
+
*/
|
|
734
|
+
async function scopeForLocalInstall(
|
|
735
|
+
deps: CommandDeps,
|
|
736
|
+
flags: InitFlags,
|
|
737
|
+
): Promise<SetupScope | 'declined'> {
|
|
617
738
|
const fromFlags = flags.setupScope ?? flags.skillsScope
|
|
618
739
|
if (fromFlags !== undefined) return fromFlags
|
|
619
|
-
if (flags.skills === false && flags.hooks === false) return undefined
|
|
620
740
|
if (deps.io.interactive === true && deps.io.select) {
|
|
621
741
|
const selected = await deps.io.select(
|
|
622
742
|
'Should this Notifai setup apply to this project only, or to every project on this machine?',
|
|
@@ -635,17 +755,30 @@ async function resolveSetupScope(
|
|
|
635
755
|
)
|
|
636
756
|
if (selected === 'project' || selected === 'global') return selected
|
|
637
757
|
deps.io.err('No setup scope selected. Pass `--setup-scope project` or `--setup-scope global`.')
|
|
638
|
-
return '
|
|
758
|
+
return 'declined'
|
|
639
759
|
}
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
return 'usage'
|
|
645
|
-
}
|
|
646
|
-
return undefined
|
|
760
|
+
deps.io.err(
|
|
761
|
+
'Unattended setup changes require an explicit scope: pass `--setup-scope project` or `--setup-scope global`.',
|
|
762
|
+
)
|
|
763
|
+
return 'declined'
|
|
647
764
|
}
|
|
648
765
|
|
|
766
|
+
/**
|
|
767
|
+
* Setup as one step at a time.
|
|
768
|
+
*
|
|
769
|
+
* The old version ran five steps in a fixed order and ended with a list of
|
|
770
|
+
* everything still outstanding. That is a report, and a report is the wrong
|
|
771
|
+
* output here: someone handed five things to do does none of them, and the
|
|
772
|
+
* order was the script's rather than the dependency graph's — it offered to
|
|
773
|
+
* install hooks after a sign-in that had just failed.
|
|
774
|
+
*
|
|
775
|
+
* So this closes what it can, then surfaces exactly one thing, the first that
|
|
776
|
+
* stands in the way. Re-running advances by one. Idempotence stops being a
|
|
777
|
+
* property to preserve and becomes the mechanism: every decision is derived
|
|
778
|
+
* from observed state, so a partial run, a second project, a fresh worktree
|
|
779
|
+
* and a revoked credential are the same code path arriving at different
|
|
780
|
+
* states rather than four branches to enumerate.
|
|
781
|
+
*/
|
|
649
782
|
export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<number> {
|
|
650
783
|
const workingDeps: CommandDeps = flags.json === true
|
|
651
784
|
? {
|
|
@@ -659,11 +792,13 @@ export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<
|
|
|
659
792
|
},
|
|
660
793
|
}
|
|
661
794
|
: deps
|
|
662
|
-
const
|
|
663
|
-
if (
|
|
664
|
-
|
|
795
|
+
const scopeFromFlags = checkSetupScopeFlags(workingDeps, flags)
|
|
796
|
+
if (scopeFromFlags === 'usage') return EXIT.usage
|
|
797
|
+
let resolved: InitFlags = {
|
|
665
798
|
...flags,
|
|
666
|
-
...(
|
|
799
|
+
...(scopeFromFlags === undefined
|
|
800
|
+
? {}
|
|
801
|
+
: { setupScope: scopeFromFlags, skillsScope: flags.skillsScope ?? scopeFromFlags }),
|
|
667
802
|
}
|
|
668
803
|
await workingDeps.io.intro?.('Notifai setup')
|
|
669
804
|
|
|
@@ -676,6 +811,11 @@ export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<
|
|
|
676
811
|
...(refresh === undefined ? {} : { previous: readiness, refresh }),
|
|
677
812
|
})
|
|
678
813
|
let failed = false
|
|
814
|
+
// What actually stopped a sign-in, when something did. Without it the close
|
|
815
|
+
// renders the state as it was before the attempt — "not paired … run
|
|
816
|
+
// `notifai init`" — which contradicts the correct line printed moments
|
|
817
|
+
// earlier and sends the reader back into the command that just failed.
|
|
818
|
+
let loginBlocker: ReadinessState | null = null
|
|
679
819
|
const attempted = new Set<string>()
|
|
680
820
|
|
|
681
821
|
// Re-assess after every successful action. This is how a browser approval or
|
|
@@ -687,23 +827,35 @@ export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<
|
|
|
687
827
|
|
|
688
828
|
for (const state of readiness.states) {
|
|
689
829
|
if (state.status === 'ready') continue
|
|
830
|
+
// Question Routing is optional automation, so it is reported and never
|
|
831
|
+
// waited on. `doctor` still fails on a broken route; `init` walks past
|
|
832
|
+
// it, because a hook diagnostic standing in front of the delivery proof
|
|
833
|
+
// means a setup that can already send never proves that it can.
|
|
834
|
+
const optional = isOptionalSetup(state.id)
|
|
835
|
+
const halt = () => {
|
|
836
|
+
if (!optional) stop = true
|
|
837
|
+
return !optional
|
|
838
|
+
}
|
|
690
839
|
if (state.status === 'unknown') {
|
|
691
|
-
|
|
692
|
-
|
|
840
|
+
if (halt()) break
|
|
841
|
+
continue
|
|
693
842
|
}
|
|
694
843
|
|
|
695
844
|
const remedy = state.remedy
|
|
696
845
|
if (remedy === undefined || attempted.has(state.id)) {
|
|
697
|
-
if (state.status === 'gap'
|
|
698
|
-
if (stop) break
|
|
846
|
+
if (state.status === 'gap' && halt()) break
|
|
699
847
|
continue
|
|
700
848
|
}
|
|
701
849
|
|
|
702
850
|
if (state.status === 'optional-gap') {
|
|
703
851
|
if (remedy.by !== 'cli' || !(await wantsOptional(workingDeps, state, resolved))) continue
|
|
704
|
-
if (
|
|
705
|
-
|
|
706
|
-
|
|
852
|
+
if (needsInstallScope(state)) {
|
|
853
|
+
const scope = await scopeForLocalInstall(workingDeps, resolved)
|
|
854
|
+
if (scope === 'declined') {
|
|
855
|
+
attempted.add(state.id)
|
|
856
|
+
continue
|
|
857
|
+
}
|
|
858
|
+
resolved = { ...resolved, setupScope: scope, skillsScope: resolved.skillsScope ?? scope }
|
|
707
859
|
}
|
|
708
860
|
attempted.add(state.id)
|
|
709
861
|
const result = await closeGap(workingDeps, state, resolved)
|
|
@@ -714,8 +866,8 @@ export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<
|
|
|
714
866
|
break
|
|
715
867
|
}
|
|
716
868
|
if (result !== 'closed') {
|
|
717
|
-
|
|
718
|
-
|
|
869
|
+
if (halt()) break
|
|
870
|
+
continue
|
|
719
871
|
}
|
|
720
872
|
readiness = await reassess(refreshAfterClose(state.id))
|
|
721
873
|
advanced = true
|
|
@@ -723,16 +875,21 @@ export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<
|
|
|
723
875
|
}
|
|
724
876
|
|
|
725
877
|
if (remedy.by === 'cli') {
|
|
726
|
-
if (
|
|
727
|
-
|
|
728
|
-
|
|
878
|
+
if (needsInstallScope(state)) {
|
|
879
|
+
const scope = await scopeForLocalInstall(workingDeps, resolved)
|
|
880
|
+
if (scope === 'declined') {
|
|
881
|
+
attempted.add(state.id)
|
|
882
|
+
if (halt()) break
|
|
883
|
+
continue
|
|
884
|
+
}
|
|
885
|
+
resolved = { ...resolved, setupScope: scope, skillsScope: resolved.skillsScope ?? scope }
|
|
729
886
|
}
|
|
730
887
|
attempted.add(state.id)
|
|
731
888
|
const result = await closeGap(workingDeps, state, resolved)
|
|
732
889
|
if (result === 'failed') failed = true
|
|
733
890
|
if (result !== 'closed') {
|
|
734
|
-
|
|
735
|
-
|
|
891
|
+
if (halt()) break
|
|
892
|
+
continue
|
|
736
893
|
}
|
|
737
894
|
readiness = await reassess(refreshAfterClose(state.id))
|
|
738
895
|
advanced = true
|
|
@@ -748,7 +905,7 @@ export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<
|
|
|
748
905
|
) {
|
|
749
906
|
attempted.add(state.id)
|
|
750
907
|
workingDeps.io.out('Opening your browser to approve this machine — Ctrl-C to stop.')
|
|
751
|
-
if ((await loginCommand(workingDeps, {})) !== EXIT.ok) {
|
|
908
|
+
if ((await loginCommand(workingDeps, {}, (blocker) => (loginBlocker = blocker))) !== EXIT.ok) {
|
|
752
909
|
failed = true
|
|
753
910
|
stop = true
|
|
754
911
|
break
|
|
@@ -776,33 +933,45 @@ export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<
|
|
|
776
933
|
}
|
|
777
934
|
|
|
778
935
|
// A human-only remedy is the first blocker for an unattended agent.
|
|
779
|
-
|
|
780
|
-
|
|
936
|
+
if (halt()) break
|
|
937
|
+
continue
|
|
781
938
|
}
|
|
782
939
|
|
|
783
940
|
if (stop || !advanced) break
|
|
784
941
|
}
|
|
785
942
|
|
|
943
|
+
const blocker = loginBlocker ?? firstRequiredBlocker(readiness)
|
|
786
944
|
if (flags.json === true) deps.io.out(JSON.stringify(readinessJson(readiness), null, 2))
|
|
787
|
-
else await printInitClose(deps, readiness, resolved)
|
|
788
|
-
const blocker = firstBlocker(readiness)
|
|
945
|
+
else await printInitClose(deps, readiness, resolved, loginBlocker)
|
|
789
946
|
if (blocker === null) return failed ? EXIT.failed : EXIT.ok
|
|
790
947
|
return failed || workingDeps.io.interactive !== true ? EXIT.failed : EXIT.ok
|
|
791
948
|
}
|
|
792
949
|
|
|
793
|
-
function isHookSubstate(id: string): boolean {
|
|
794
|
-
return id === 'hooks' || id.startsWith('hooks-') || id === 'question-routing-settings'
|
|
795
|
-
}
|
|
796
|
-
|
|
797
950
|
function leftoverOptionals(readiness: Readiness, flags: InitFlags): ReadinessState[] {
|
|
798
951
|
return readiness.states.filter((state) => {
|
|
799
952
|
if (state.status !== 'optional-gap') return false
|
|
800
|
-
if (
|
|
953
|
+
if (isOptionalAutomation(state.id)) return false
|
|
954
|
+
// Not an install anyone declined, so "Optional, not set up" would misread
|
|
955
|
+
// it; it gets its own line below.
|
|
956
|
+
if (state.id === 'cli-bin') return false
|
|
801
957
|
if (state.id === 'skill' && flags.skills === false) return false
|
|
802
958
|
return true
|
|
803
959
|
})
|
|
804
960
|
}
|
|
805
961
|
|
|
962
|
+
/**
|
|
963
|
+
* A `notifai` the reader cannot type.
|
|
964
|
+
*
|
|
965
|
+
* Said on the way out of every run, successful or not, because every next step
|
|
966
|
+
* this command prints — here, in the skill, in the README — names a command
|
|
967
|
+
* that will not be found until it is fixed.
|
|
968
|
+
*/
|
|
969
|
+
function printMissingCliBin(deps: CommandDeps, readiness: Readiness): void {
|
|
970
|
+
const state = readiness.states.find((candidate) => candidate.id === 'cli-bin')
|
|
971
|
+
if (state?.status !== 'optional-gap') return
|
|
972
|
+
deps.io.out(`Heads up: ${state.detail}. ${remedyLine(state)}`)
|
|
973
|
+
}
|
|
974
|
+
|
|
806
975
|
function printOptionalLeftovers(deps: CommandDeps, leftovers: readonly ReadinessState[]): void {
|
|
807
976
|
for (const state of leftovers) {
|
|
808
977
|
deps.io.out(`Optional, not set up — ${remedyLine(state)}`)
|
|
@@ -813,8 +982,9 @@ async function printInitClose(
|
|
|
813
982
|
deps: CommandDeps,
|
|
814
983
|
readiness: Readiness,
|
|
815
984
|
flags: InitFlags,
|
|
985
|
+
stoppedBy: ReadinessState | null = null,
|
|
816
986
|
): Promise<void> {
|
|
817
|
-
const blocker =
|
|
987
|
+
const blocker = stoppedBy ?? firstRequiredBlocker(readiness)
|
|
818
988
|
const canSend = readiness.states.find((state) => state.id === 'devices')?.status === 'ready'
|
|
819
989
|
const questions = questionRoutingReady(readiness)
|
|
820
990
|
const leftovers = leftoverOptionals(readiness, flags).filter(
|
|
@@ -823,10 +993,16 @@ async function printInitClose(
|
|
|
823
993
|
|
|
824
994
|
if (blocker?.id === 'contract') {
|
|
825
995
|
deps.io.out(blocker.detail)
|
|
826
|
-
deps.io.out(
|
|
996
|
+
deps.io.out(
|
|
997
|
+
blocker.remedy?.by !== 'user-elsewhere' && blocker.remedy?.command !== undefined
|
|
998
|
+
? blocker.remedy.command
|
|
999
|
+
: updateCliCommand(deps),
|
|
1000
|
+
)
|
|
827
1001
|
return
|
|
828
1002
|
}
|
|
829
1003
|
|
|
1004
|
+
printMissingCliBin(deps, readiness)
|
|
1005
|
+
|
|
830
1006
|
if (deps.io.interactive === true) {
|
|
831
1007
|
if (blocker === null) {
|
|
832
1008
|
const lines = [
|
|
@@ -845,11 +1021,13 @@ async function printInitClose(
|
|
|
845
1021
|
await deps.io.outro?.('All set ✨')
|
|
846
1022
|
return
|
|
847
1023
|
}
|
|
848
|
-
|
|
1024
|
+
// No framed copy of what the next three lines already say. The "Ready"
|
|
1025
|
+
// note above adds something the close does not; this one only made a
|
|
1026
|
+
// stopped visit state its one step twice, box then text.
|
|
849
1027
|
deps.io.out(`Next: ${blocker.title} — ${blocker.detail}`)
|
|
850
1028
|
deps.io.out(` ${remedyLine(blocker)}`)
|
|
851
|
-
if (blocker
|
|
852
|
-
deps.io.out(
|
|
1029
|
+
if (shouldRestateInit(blocker)) {
|
|
1030
|
+
deps.io.out(` Then re-run \`${SETUP_COMMAND}\` and it will pick up from here.`)
|
|
853
1031
|
}
|
|
854
1032
|
await deps.io.outro?.('One step remains (above)')
|
|
855
1033
|
return
|
|
@@ -866,7 +1044,12 @@ async function printInitClose(
|
|
|
866
1044
|
}
|
|
867
1045
|
deps.io.out(`Next: ${blocker.title} — ${blocker.detail}`)
|
|
868
1046
|
deps.io.out(` ${remedyLine(blocker)}`)
|
|
869
|
-
if (blocker
|
|
870
|
-
deps.io.out(
|
|
1047
|
+
if (shouldRestateInit(blocker)) {
|
|
1048
|
+
deps.io.out(` Then re-run \`${SETUP_COMMAND}\` and it will pick up from here.`)
|
|
871
1049
|
}
|
|
872
1050
|
}
|
|
1051
|
+
|
|
1052
|
+
function shouldRestateInit(blocker: ReadinessState): boolean {
|
|
1053
|
+
if (blocker.remedy?.by !== 'user-elsewhere' && blocker.remedy?.by !== 'user-here') return false
|
|
1054
|
+
return !('command' in blocker.remedy) || blocker.remedy.command !== SETUP_COMMAND
|
|
1055
|
+
}
|