@raidiant/notifai 10.1.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 +17 -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 +8 -1
- 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 +245 -87
- 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/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/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/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/setup-destinations.d.ts +23 -0
- package/dist/setup-destinations.js +44 -0
- package/dist/setup-destinations.js.map +1 -0
- 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 +44 -11
- package/src/commands-core.ts +18 -4
- package/src/commands-devices.ts +23 -16
- package/src/commands-doctor.ts +116 -55
- package/src/commands-hooks.ts +66 -16
- package/src/commands-init.ts +265 -88
- package/src/commands-send.ts +145 -39
- package/src/commands-update.ts +180 -0
- package/src/commands.test.ts +518 -49
- package/src/commands.ts +2 -0
- package/src/hook-adapter.ts +14 -0
- package/src/hooks.test.ts +275 -0
- package/src/hooks.ts +270 -18
- 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/program.test.ts +35 -0
- package/src/program.ts +28 -4
- package/src/question-settlement-process.ts +47 -0
- package/src/readiness.ts +35 -2
- package/src/setup-destinations.test.ts +41 -0
- package/src/setup-destinations.ts +50 -0
- package/src/skill.test.ts +11 -0
- 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,
|
|
@@ -26,13 +28,18 @@ import { loginCommand } from './commands-auth.js'
|
|
|
26
28
|
import {
|
|
27
29
|
EXIT,
|
|
28
30
|
SETUP_COMMAND,
|
|
29
|
-
UPDATE_CLI_COMMAND,
|
|
30
31
|
authedClient,
|
|
31
32
|
loadLoggedConfig,
|
|
32
33
|
reportError,
|
|
34
|
+
updateCliCommand,
|
|
33
35
|
type CommandDeps,
|
|
34
36
|
} from './commands-core.js'
|
|
35
|
-
import { readyCompanionDevices
|
|
37
|
+
import { readyCompanionDevices } from './commands-devices.js'
|
|
38
|
+
import {
|
|
39
|
+
companionPlatformLabel,
|
|
40
|
+
setupCompanionUrl,
|
|
41
|
+
type CompanionPlatform,
|
|
42
|
+
} from './setup-destinations.js'
|
|
36
43
|
import { assessReadiness, remedyLine } from './commands-doctor.js'
|
|
37
44
|
import { hooksInstallCommand, pickHarnessesToInstall } from './commands-hooks.js'
|
|
38
45
|
import {
|
|
@@ -45,6 +52,8 @@ import {
|
|
|
45
52
|
} from './commands-setup-proof.js'
|
|
46
53
|
import { listScopedNotifaiSkills, SKILLS_SOURCE } from './commands-skill.js'
|
|
47
54
|
import { enableProject, projectBinding } from './project-enablement.js'
|
|
55
|
+
import { inspectCliInstallations } from './cli-bin.js'
|
|
56
|
+
import { installHookAdapter } from './hook-adapter.js'
|
|
48
57
|
|
|
49
58
|
// ---------------------------------------------------------------------------
|
|
50
59
|
// init
|
|
@@ -125,7 +134,10 @@ async function closeGap(
|
|
|
125
134
|
flags: InitFlags,
|
|
126
135
|
): Promise<GapCloseResult> {
|
|
127
136
|
if (state.id === 'project') {
|
|
128
|
-
|
|
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.
|
|
129
141
|
const slug = projectSlugFrom(flags.projectId ?? path.basename(deps.cwd))
|
|
130
142
|
const configPath =
|
|
131
143
|
flags.setupScope === 'global'
|
|
@@ -160,6 +172,25 @@ async function closeGap(
|
|
|
160
172
|
return ok ? 'closed' : 'failed'
|
|
161
173
|
}
|
|
162
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
|
+
|
|
163
194
|
if (state.id === 'skill') {
|
|
164
195
|
if (deps.nativeSkills === undefined) {
|
|
165
196
|
deps.io.err('Skill installation failed — the native `npx skills` flow is unavailable.')
|
|
@@ -232,12 +263,44 @@ function deviceBridgeMessage(devices: readonly RoutableDevice[]): string {
|
|
|
232
263
|
return 'Waiting for a Companion device to become ready…'
|
|
233
264
|
}
|
|
234
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
|
+
|
|
235
297
|
/**
|
|
236
298
|
* Observe the supported Device Installation path while the user finishes the
|
|
237
|
-
* app-side work. The
|
|
238
|
-
*
|
|
239
|
-
*
|
|
240
|
-
* 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.
|
|
241
304
|
*
|
|
242
305
|
* The wait budget is stated up front. On expiry we say the *timer* expired —
|
|
243
306
|
* not the setup — and offer another budget when a human is present. Agents
|
|
@@ -256,26 +319,36 @@ async function waitForReadyDevice(deps: CommandDeps, state: ReadinessState): Pro
|
|
|
256
319
|
}
|
|
257
320
|
|
|
258
321
|
const budgetLabel = formatWaitBudget(DEVICE_BRIDGE_TIMEOUT_MS)
|
|
259
|
-
|
|
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)`
|
|
260
333
|
await deps.io.note?.(
|
|
261
334
|
[
|
|
262
335
|
state.detail,
|
|
263
336
|
remedy.summary,
|
|
264
|
-
|
|
265
|
-
`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.`,
|
|
266
339
|
].join('\n'),
|
|
267
|
-
'Finish setup on your
|
|
340
|
+
'Finish setup on your phone',
|
|
268
341
|
)
|
|
269
342
|
|
|
270
|
-
// Open the
|
|
271
|
-
//
|
|
272
|
-
if (await deps.io.confirm('Open
|
|
273
|
-
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)
|
|
274
347
|
}
|
|
275
348
|
|
|
276
|
-
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))) {
|
|
277
350
|
deps.io.out(
|
|
278
|
-
`OK — finish
|
|
351
|
+
`OK — finish that when you can (steps: ${setupUrl}), then re-run \`notifai init\`.`,
|
|
279
352
|
)
|
|
280
353
|
return 'pending'
|
|
281
354
|
}
|
|
@@ -324,7 +397,7 @@ async function waitForReadyDevice(deps: CommandDeps, state: ReadinessState): Pro
|
|
|
324
397
|
)
|
|
325
398
|
deps.io.err(deviceBridgeMessage(lastDevices).replace(/…$/, '.'))
|
|
326
399
|
deps.io.err(
|
|
327
|
-
`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}).`,
|
|
328
401
|
)
|
|
329
402
|
|
|
330
403
|
const keepWaiting = await deps.io.confirm(
|
|
@@ -337,29 +410,47 @@ async function waitForReadyDevice(deps: CommandDeps, state: ReadinessState): Pro
|
|
|
337
410
|
)
|
|
338
411
|
return 'pending'
|
|
339
412
|
}
|
|
340
|
-
spinner?.message(`Waiting another ${budgetLabel} for a Companion
|
|
413
|
+
spinner?.message(`Waiting another ${budgetLabel} for a Companion App…`)
|
|
341
414
|
deadline = now() + DEVICE_BRIDGE_TIMEOUT_MS
|
|
342
415
|
}
|
|
343
416
|
}
|
|
344
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
|
+
*/
|
|
345
434
|
function setupProofDraft(
|
|
346
435
|
config: CliConfig,
|
|
347
436
|
device: RoutableDevice,
|
|
348
437
|
): ReturnType<typeof buildDraft> {
|
|
349
438
|
const project = config.project.value
|
|
350
439
|
return buildDraft(config, {
|
|
351
|
-
title: '
|
|
440
|
+
title: 'Your agents can reach you',
|
|
352
441
|
body:
|
|
353
442
|
project === null
|
|
354
|
-
? '
|
|
355
|
-
: `
|
|
356
|
-
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',
|
|
357
446
|
platform: device.platform,
|
|
358
447
|
device: [device.device_id],
|
|
359
|
-
|
|
448
|
+
// Noticeable, not intrusive: a normal banner and sound. Deliberately not
|
|
449
|
+
// `time_sensitive` — a setup confirmation has no business breaking a Focus.
|
|
450
|
+
//
|
|
360
451
|
// Android has no caller-selected interruption level. Explicit null keeps an
|
|
361
452
|
// Apple preference in config from leaking into this Android-only proof.
|
|
362
|
-
level: device.platform === 'ios' ? '
|
|
453
|
+
level: device.platform === 'ios' ? 'active' : null,
|
|
363
454
|
collapseKey: 'notifai-setup-verification',
|
|
364
455
|
})
|
|
365
456
|
}
|
|
@@ -544,7 +635,7 @@ async function runSetupProof(deps: CommandDeps): Promise<GapCloseResult> {
|
|
|
544
635
|
|
|
545
636
|
/** Closing a local gap cannot have changed the service, the keychain, or devices. */
|
|
546
637
|
function refreshAfterClose(id: string): readonly ReadinessRefresh[] | undefined {
|
|
547
|
-
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'
|
|
548
639
|
? ['local']
|
|
549
640
|
: undefined
|
|
550
641
|
}
|
|
@@ -559,7 +650,11 @@ function wantsOptional(deps: CommandDeps, state: ReadinessState, flags: InitFlag
|
|
|
559
650
|
// asked about, for a human and an agent alike.
|
|
560
651
|
if (state.id === 'project') return Promise.resolve(true)
|
|
561
652
|
if (state.id === 'project-enablement') return Promise.resolve(true)
|
|
562
|
-
|
|
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
|
|
563
658
|
if (explicit !== undefined) return Promise.resolve(explicit)
|
|
564
659
|
// An agent is never asked, and never assumed into a change it did not
|
|
565
660
|
// request: silence means no, and the summary says what was skipped.
|
|
@@ -571,30 +666,21 @@ function wantsOptional(deps: CommandDeps, state: ReadinessState, flags: InitFlag
|
|
|
571
666
|
return deps.io.confirm(question, true)
|
|
572
667
|
}
|
|
573
668
|
|
|
574
|
-
/**
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
* output here: someone handed five things to do does none of them, and the
|
|
580
|
-
* order was the script's rather than the dependency graph's — it offered to
|
|
581
|
-
* install hooks after a sign-in that had just failed.
|
|
582
|
-
*
|
|
583
|
-
* So this closes what it can, then surfaces exactly one thing, the first that
|
|
584
|
-
* stands in the way. Re-running advances by one. Idempotence stops being a
|
|
585
|
-
* property to preserve and becomes the mechanism: every decision is derived
|
|
586
|
-
* from observed state, so a partial run, a second project, a fresh worktree
|
|
587
|
-
* and a revoked credential are the same code path arriving at different
|
|
588
|
-
* states rather than four branches to enumerate.
|
|
589
|
-
*/
|
|
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
|
+
|
|
590
674
|
function isSetupScope(value: string | undefined): value is SetupScope {
|
|
591
675
|
return value === 'project' || value === 'global'
|
|
592
676
|
}
|
|
593
677
|
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
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' {
|
|
598
684
|
if (flags.setupScope !== undefined && !isSetupScope(flags.setupScope)) {
|
|
599
685
|
deps.io.err('Invalid setup scope. Choose `project` or `global`.')
|
|
600
686
|
return 'usage'
|
|
@@ -615,9 +701,42 @@ async function resolveSetupScope(
|
|
|
615
701
|
deps.io.err('`--skills-scope` requires `--skills`. Use `--setup-scope` to choose project or machine for the whole setup.')
|
|
616
702
|
return 'usage'
|
|
617
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'> {
|
|
618
738
|
const fromFlags = flags.setupScope ?? flags.skillsScope
|
|
619
739
|
if (fromFlags !== undefined) return fromFlags
|
|
620
|
-
if (flags.skills === false && flags.hooks === false) return undefined
|
|
621
740
|
if (deps.io.interactive === true && deps.io.select) {
|
|
622
741
|
const selected = await deps.io.select(
|
|
623
742
|
'Should this Notifai setup apply to this project only, or to every project on this machine?',
|
|
@@ -636,17 +755,30 @@ async function resolveSetupScope(
|
|
|
636
755
|
)
|
|
637
756
|
if (selected === 'project' || selected === 'global') return selected
|
|
638
757
|
deps.io.err('No setup scope selected. Pass `--setup-scope project` or `--setup-scope global`.')
|
|
639
|
-
return '
|
|
640
|
-
}
|
|
641
|
-
if (flags.skills === true || flags.hooks === true) {
|
|
642
|
-
deps.io.err(
|
|
643
|
-
'Unattended setup changes require an explicit scope: pass `--setup-scope project` or `--setup-scope global`.',
|
|
644
|
-
)
|
|
645
|
-
return 'usage'
|
|
758
|
+
return 'declined'
|
|
646
759
|
}
|
|
647
|
-
|
|
760
|
+
deps.io.err(
|
|
761
|
+
'Unattended setup changes require an explicit scope: pass `--setup-scope project` or `--setup-scope global`.',
|
|
762
|
+
)
|
|
763
|
+
return 'declined'
|
|
648
764
|
}
|
|
649
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
|
+
*/
|
|
650
782
|
export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<number> {
|
|
651
783
|
const workingDeps: CommandDeps = flags.json === true
|
|
652
784
|
? {
|
|
@@ -660,11 +792,13 @@ export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<
|
|
|
660
792
|
},
|
|
661
793
|
}
|
|
662
794
|
: deps
|
|
663
|
-
const
|
|
664
|
-
if (
|
|
665
|
-
|
|
795
|
+
const scopeFromFlags = checkSetupScopeFlags(workingDeps, flags)
|
|
796
|
+
if (scopeFromFlags === 'usage') return EXIT.usage
|
|
797
|
+
let resolved: InitFlags = {
|
|
666
798
|
...flags,
|
|
667
|
-
...(
|
|
799
|
+
...(scopeFromFlags === undefined
|
|
800
|
+
? {}
|
|
801
|
+
: { setupScope: scopeFromFlags, skillsScope: flags.skillsScope ?? scopeFromFlags }),
|
|
668
802
|
}
|
|
669
803
|
await workingDeps.io.intro?.('Notifai setup')
|
|
670
804
|
|
|
@@ -677,6 +811,11 @@ export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<
|
|
|
677
811
|
...(refresh === undefined ? {} : { previous: readiness, refresh }),
|
|
678
812
|
})
|
|
679
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
|
|
680
819
|
const attempted = new Set<string>()
|
|
681
820
|
|
|
682
821
|
// Re-assess after every successful action. This is how a browser approval or
|
|
@@ -688,23 +827,35 @@ export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<
|
|
|
688
827
|
|
|
689
828
|
for (const state of readiness.states) {
|
|
690
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
|
+
}
|
|
691
839
|
if (state.status === 'unknown') {
|
|
692
|
-
|
|
693
|
-
|
|
840
|
+
if (halt()) break
|
|
841
|
+
continue
|
|
694
842
|
}
|
|
695
843
|
|
|
696
844
|
const remedy = state.remedy
|
|
697
845
|
if (remedy === undefined || attempted.has(state.id)) {
|
|
698
|
-
if (state.status === 'gap'
|
|
699
|
-
if (stop) break
|
|
846
|
+
if (state.status === 'gap' && halt()) break
|
|
700
847
|
continue
|
|
701
848
|
}
|
|
702
849
|
|
|
703
850
|
if (state.status === 'optional-gap') {
|
|
704
851
|
if (remedy.by !== 'cli' || !(await wantsOptional(workingDeps, state, resolved))) continue
|
|
705
|
-
if (
|
|
706
|
-
|
|
707
|
-
|
|
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 }
|
|
708
859
|
}
|
|
709
860
|
attempted.add(state.id)
|
|
710
861
|
const result = await closeGap(workingDeps, state, resolved)
|
|
@@ -715,8 +866,8 @@ export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<
|
|
|
715
866
|
break
|
|
716
867
|
}
|
|
717
868
|
if (result !== 'closed') {
|
|
718
|
-
|
|
719
|
-
|
|
869
|
+
if (halt()) break
|
|
870
|
+
continue
|
|
720
871
|
}
|
|
721
872
|
readiness = await reassess(refreshAfterClose(state.id))
|
|
722
873
|
advanced = true
|
|
@@ -724,16 +875,21 @@ export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<
|
|
|
724
875
|
}
|
|
725
876
|
|
|
726
877
|
if (remedy.by === 'cli') {
|
|
727
|
-
if (
|
|
728
|
-
|
|
729
|
-
|
|
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 }
|
|
730
886
|
}
|
|
731
887
|
attempted.add(state.id)
|
|
732
888
|
const result = await closeGap(workingDeps, state, resolved)
|
|
733
889
|
if (result === 'failed') failed = true
|
|
734
890
|
if (result !== 'closed') {
|
|
735
|
-
|
|
736
|
-
|
|
891
|
+
if (halt()) break
|
|
892
|
+
continue
|
|
737
893
|
}
|
|
738
894
|
readiness = await reassess(refreshAfterClose(state.id))
|
|
739
895
|
advanced = true
|
|
@@ -749,7 +905,7 @@ export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<
|
|
|
749
905
|
) {
|
|
750
906
|
attempted.add(state.id)
|
|
751
907
|
workingDeps.io.out('Opening your browser to approve this machine — Ctrl-C to stop.')
|
|
752
|
-
if ((await loginCommand(workingDeps, {})) !== EXIT.ok) {
|
|
908
|
+
if ((await loginCommand(workingDeps, {}, (blocker) => (loginBlocker = blocker))) !== EXIT.ok) {
|
|
753
909
|
failed = true
|
|
754
910
|
stop = true
|
|
755
911
|
break
|
|
@@ -777,33 +933,45 @@ export async function initCommand(deps: CommandDeps, flags: InitFlags): Promise<
|
|
|
777
933
|
}
|
|
778
934
|
|
|
779
935
|
// A human-only remedy is the first blocker for an unattended agent.
|
|
780
|
-
|
|
781
|
-
|
|
936
|
+
if (halt()) break
|
|
937
|
+
continue
|
|
782
938
|
}
|
|
783
939
|
|
|
784
940
|
if (stop || !advanced) break
|
|
785
941
|
}
|
|
786
942
|
|
|
943
|
+
const blocker = loginBlocker ?? firstRequiredBlocker(readiness)
|
|
787
944
|
if (flags.json === true) deps.io.out(JSON.stringify(readinessJson(readiness), null, 2))
|
|
788
|
-
else await printInitClose(deps, readiness, resolved)
|
|
789
|
-
const blocker = firstBlocker(readiness)
|
|
945
|
+
else await printInitClose(deps, readiness, resolved, loginBlocker)
|
|
790
946
|
if (blocker === null) return failed ? EXIT.failed : EXIT.ok
|
|
791
947
|
return failed || workingDeps.io.interactive !== true ? EXIT.failed : EXIT.ok
|
|
792
948
|
}
|
|
793
949
|
|
|
794
|
-
function isHookSubstate(id: string): boolean {
|
|
795
|
-
return id === 'hooks' || id.startsWith('hooks-') || id === 'question-routing-settings'
|
|
796
|
-
}
|
|
797
|
-
|
|
798
950
|
function leftoverOptionals(readiness: Readiness, flags: InitFlags): ReadinessState[] {
|
|
799
951
|
return readiness.states.filter((state) => {
|
|
800
952
|
if (state.status !== 'optional-gap') return false
|
|
801
|
-
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
|
|
802
957
|
if (state.id === 'skill' && flags.skills === false) return false
|
|
803
958
|
return true
|
|
804
959
|
})
|
|
805
960
|
}
|
|
806
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
|
+
|
|
807
975
|
function printOptionalLeftovers(deps: CommandDeps, leftovers: readonly ReadinessState[]): void {
|
|
808
976
|
for (const state of leftovers) {
|
|
809
977
|
deps.io.out(`Optional, not set up — ${remedyLine(state)}`)
|
|
@@ -814,8 +982,9 @@ async function printInitClose(
|
|
|
814
982
|
deps: CommandDeps,
|
|
815
983
|
readiness: Readiness,
|
|
816
984
|
flags: InitFlags,
|
|
985
|
+
stoppedBy: ReadinessState | null = null,
|
|
817
986
|
): Promise<void> {
|
|
818
|
-
const blocker =
|
|
987
|
+
const blocker = stoppedBy ?? firstRequiredBlocker(readiness)
|
|
819
988
|
const canSend = readiness.states.find((state) => state.id === 'devices')?.status === 'ready'
|
|
820
989
|
const questions = questionRoutingReady(readiness)
|
|
821
990
|
const leftovers = leftoverOptionals(readiness, flags).filter(
|
|
@@ -824,10 +993,16 @@ async function printInitClose(
|
|
|
824
993
|
|
|
825
994
|
if (blocker?.id === 'contract') {
|
|
826
995
|
deps.io.out(blocker.detail)
|
|
827
|
-
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
|
+
)
|
|
828
1001
|
return
|
|
829
1002
|
}
|
|
830
1003
|
|
|
1004
|
+
printMissingCliBin(deps, readiness)
|
|
1005
|
+
|
|
831
1006
|
if (deps.io.interactive === true) {
|
|
832
1007
|
if (blocker === null) {
|
|
833
1008
|
const lines = [
|
|
@@ -846,7 +1021,9 @@ async function printInitClose(
|
|
|
846
1021
|
await deps.io.outro?.('All set ✨')
|
|
847
1022
|
return
|
|
848
1023
|
}
|
|
849
|
-
|
|
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.
|
|
850
1027
|
deps.io.out(`Next: ${blocker.title} — ${blocker.detail}`)
|
|
851
1028
|
deps.io.out(` ${remedyLine(blocker)}`)
|
|
852
1029
|
if (shouldRestateInit(blocker)) {
|