quoxcli 0.0.0-stage → 0.2.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/LICENSE +35 -0
- package/README.md +142 -2
- package/bin/quox.js +292 -0
- package/bin/quoxon.js +8 -0
- package/lib/api/endpoints/admin.js +125 -0
- package/lib/api/endpoints/aee.js +38 -0
- package/lib/api/endpoints/agent-attach.js +126 -0
- package/lib/api/endpoints/agents.js +56 -0
- package/lib/api/endpoints/assistants.js +50 -0
- package/lib/api/endpoints/audit.js +14 -0
- package/lib/api/endpoints/auth.js +50 -0
- package/lib/api/endpoints/awareness.js +34 -0
- package/lib/api/endpoints/backup-schedules.js +20 -0
- package/lib/api/endpoints/backups.js +86 -0
- package/lib/api/endpoints/brain.js +140 -0
- package/lib/api/endpoints/budgets.js +62 -0
- package/lib/api/endpoints/calendarq.js +71 -0
- package/lib/api/endpoints/catalog.js +14 -0
- package/lib/api/endpoints/chat.js +54 -0
- package/lib/api/endpoints/consent.js +26 -0
- package/lib/api/endpoints/contactsq.js +66 -0
- package/lib/api/endpoints/conversations.js +32 -0
- package/lib/api/endpoints/custom-roles.js +92 -0
- package/lib/api/endpoints/divisions.js +56 -0
- package/lib/api/endpoints/domains.js +38 -0
- package/lib/api/endpoints/engagement.js +231 -0
- package/lib/api/endpoints/engine.js +11 -0
- package/lib/api/endpoints/entity.js +50 -0
- package/lib/api/endpoints/executions.js +32 -0
- package/lib/api/endpoints/file.js +20 -0
- package/lib/api/endpoints/files.js +26 -0
- package/lib/api/endpoints/github.js +21 -0
- package/lib/api/endpoints/governance.js +43 -0
- package/lib/api/endpoints/hat.js +36 -0
- package/lib/api/endpoints/inbox.js +32 -0
- package/lib/api/endpoints/inboxq.js +120 -0
- package/lib/api/endpoints/index.js +228 -0
- package/lib/api/endpoints/invites.js +19 -0
- package/lib/api/endpoints/llm-policy.js +43 -0
- package/lib/api/endpoints/lucid.js +89 -0
- package/lib/api/endpoints/members.js +32 -0
- package/lib/api/endpoints/memory.js +75 -0
- package/lib/api/endpoints/mfa.js +32 -0
- package/lib/api/endpoints/mon.js +178 -0
- package/lib/api/endpoints/notesq.js +43 -0
- package/lib/api/endpoints/objectives.js +62 -0
- package/lib/api/endpoints/orgs.js +38 -0
- package/lib/api/endpoints/plugins.js +134 -0
- package/lib/api/endpoints/powers.js +75 -0
- package/lib/api/endpoints/prompts.js +69 -0
- package/lib/api/endpoints/qlarity.js +122 -0
- package/lib/api/endpoints/quoxbox.js +55 -0
- package/lib/api/endpoints/quoxchat.js +54 -0
- package/lib/api/endpoints/quoxflow.js +32 -0
- package/lib/api/endpoints/quoxplan.js +114 -0
- package/lib/api/endpoints/quoxseo.js +108 -0
- package/lib/api/endpoints/reach.js +47 -0
- package/lib/api/endpoints/runs.js +38 -0
- package/lib/api/endpoints/safety.js +46 -0
- package/lib/api/endpoints/scim.js +46 -0
- package/lib/api/endpoints/scopes.js +14 -0
- package/lib/api/endpoints/security.js +69 -0
- package/lib/api/endpoints/setup.js +90 -0
- package/lib/api/endpoints/skills.js +180 -0
- package/lib/api/endpoints/sso.js +67 -0
- package/lib/api/endpoints/tags.js +32 -0
- package/lib/api/endpoints/teams.js +56 -0
- package/lib/api/endpoints/telephony.js +46 -0
- package/lib/api/endpoints/tg.js +56 -0
- package/lib/api/endpoints/tools.js +50 -0
- package/lib/api/endpoints/traces.js +157 -0
- package/lib/api/endpoints/triggers.js +70 -0
- package/lib/api/endpoints/user-library.js +41 -0
- package/lib/api/endpoints/users.js +20 -0
- package/lib/api/endpoints/v1.js +499 -0
- package/lib/api/endpoints/vault.js +192 -0
- package/lib/api/endpoints/voice.js +22 -0
- package/lib/api/endpoints/volt.js +84 -0
- package/lib/api/endpoints/ward.js +40 -0
- package/lib/api/endpoints/webhooks.js +46 -0
- package/lib/api/endpoints/workflows.js +162 -0
- package/lib/api/endpoints/x.js +24 -0
- package/lib/api/endpoints.js +15 -0
- package/lib/commands/admin.js +359 -0
- package/lib/commands/aee.js +99 -0
- package/lib/commands/agent.js +201 -0
- package/lib/commands/agentAttach.js +1440 -0
- package/lib/commands/approvals.js +47 -0
- package/lib/commands/assistant.js +114 -0
- package/lib/commands/auth.js +458 -0
- package/lib/commands/awareness.js +88 -0
- package/lib/commands/backup.js +373 -0
- package/lib/commands/brain.js +994 -0
- package/lib/commands/budget.js +241 -0
- package/lib/commands/build.js +156 -0
- package/lib/commands/calendar.js +216 -0
- package/lib/commands/capabilities.js +34 -0
- package/lib/commands/chat.js +240 -0
- package/lib/commands/claude.js +354 -0
- package/lib/commands/config-cmd.js +106 -0
- package/lib/commands/consent.js +84 -0
- package/lib/commands/contacts.js +967 -0
- package/lib/commands/contactsSelftest.js +132 -0
- package/lib/commands/conversation.js +74 -0
- package/lib/commands/department.js +75 -0
- package/lib/commands/dev.js +620 -0
- package/lib/commands/devResident.js +314 -0
- package/lib/commands/devSupervisor.js +1050 -0
- package/lib/commands/discord.js +96 -0
- package/lib/commands/doctor.js +181 -0
- package/lib/commands/domain.js +114 -0
- package/lib/commands/done.js +277 -0
- package/lib/commands/engagement.js +586 -0
- package/lib/commands/entity.js +119 -0
- package/lib/commands/facebook.js +212 -0
- package/lib/commands/file.js +128 -0
- package/lib/commands/fleet.js +882 -0
- package/lib/commands/governance.js +638 -0
- package/lib/commands/gripe.js +324 -0
- package/lib/commands/hat.js +205 -0
- package/lib/commands/helpers.js +79 -0
- package/lib/commands/inbox.js +122 -0
- package/lib/commands/instagram.js +264 -0
- package/lib/commands/integration.js +100 -0
- package/lib/commands/inventory.js +115 -0
- package/lib/commands/invites.js +45 -0
- package/lib/commands/licence.js +280 -0
- package/lib/commands/linkedin.js +178 -0
- package/lib/commands/llm-policy.js +117 -0
- package/lib/commands/llm.js +45 -0
- package/lib/commands/lucid.js +591 -0
- package/lib/commands/mail.js +531 -0
- package/lib/commands/memory.js +284 -0
- package/lib/commands/mon.js +514 -0
- package/lib/commands/monitoring.js +86 -0
- package/lib/commands/motion.js +22 -0
- package/lib/commands/notes.js +1246 -0
- package/lib/commands/notesBatch.js +160 -0
- package/lib/commands/notesBridges.js +56 -0
- package/lib/commands/notesLinks.js +60 -0
- package/lib/commands/notification.js +57 -0
- package/lib/commands/nucleus.js +92 -0
- package/lib/commands/objective.js +229 -0
- package/lib/commands/onboard.js +328 -0
- package/lib/commands/open.js +56 -0
- package/lib/commands/org.js +266 -0
- package/lib/commands/plan.js +906 -0
- package/lib/commands/plugin-doctor.js +1091 -0
- package/lib/commands/plugin.js +539 -0
- package/lib/commands/policy.js +78 -0
- package/lib/commands/powers.js +296 -0
- package/lib/commands/prompt.js +350 -0
- package/lib/commands/qlarity.js +690 -0
- package/lib/commands/quoxbox.js +270 -0
- package/lib/commands/quoxchat.js +266 -0
- package/lib/commands/quoxseo.js +340 -0
- package/lib/commands/reach.js +158 -0
- package/lib/commands/role.js +286 -0
- package/lib/commands/run.js +132 -0
- package/lib/commands/safety.js +107 -0
- package/lib/commands/scim.js +88 -0
- package/lib/commands/scope.js +58 -0
- package/lib/commands/security.js +157 -0
- package/lib/commands/service.js +89 -0
- package/lib/commands/settings.js +99 -0
- package/lib/commands/setup.js +543 -0
- package/lib/commands/skillManifest.js +113 -0
- package/lib/commands/skillRightsFormat.js +38 -0
- package/lib/commands/skills.js +644 -0
- package/lib/commands/slot.js +1198 -0
- package/lib/commands/sso.js +229 -0
- package/lib/commands/status.js +137 -0
- package/lib/commands/tag.js +91 -0
- package/lib/commands/team.js +140 -0
- package/lib/commands/tg.js +240 -0
- package/lib/commands/tiktok.js +218 -0
- package/lib/commands/tool.js +216 -0
- package/lib/commands/trace.js +525 -0
- package/lib/commands/trigger.js +128 -0
- package/lib/commands/vault.js +1424 -0
- package/lib/commands/voice.js +182 -0
- package/lib/commands/volt.js +528 -0
- package/lib/commands/watch.js +115 -0
- package/lib/commands/webhook.js +137 -0
- package/lib/commands/workflow.js +503 -0
- package/lib/commands/x.js +439 -0
- package/lib/core/actingAs.js +136 -0
- package/lib/core/aee.js +53 -0
- package/lib/core/agentAlias.js +163 -0
- package/lib/core/approvalContext.js +61 -0
- package/lib/core/auth.js +350 -0
- package/lib/core/banner.js +143 -0
- package/lib/core/brainPrime.js +90 -0
- package/lib/core/callerSource.js +129 -0
- package/lib/core/claudeLs.js +94 -0
- package/lib/core/client.js +584 -0
- package/lib/core/completions.js +163 -0
- package/lib/core/config.js +551 -0
- package/lib/core/confirm.js +37 -0
- package/lib/core/deeplinks.js +76 -0
- package/lib/core/doneEngineFetch.js +219 -0
- package/lib/core/errors.js +132 -0
- package/lib/core/global-flags.js +90 -0
- package/lib/core/governedSession.js +402 -0
- package/lib/core/lazyCommands.js +119 -0
- package/lib/core/liveSessionsReport.js +112 -0
- package/lib/core/org-override.js +52 -0
- package/lib/core/output.js +239 -0
- package/lib/core/prodEnv.js +7 -0
- package/lib/core/quoxglyph.js +158 -0
- package/lib/core/residentAlarm.js +282 -0
- package/lib/core/residentAuth.js +243 -0
- package/lib/core/residentPump.js +229 -0
- package/lib/core/residentSeed.js +104 -0
- package/lib/core/sessionHeartbeat.js +192 -0
- package/lib/core/sessionsOverview.js +160 -0
- package/lib/core/slotKeeper.js +282 -0
- package/lib/core/supervisorEngine.js +864 -0
- package/lib/core/systemdInstaller.js +256 -0
- package/lib/core/version.js +87 -0
- package/lib/core/websiteSession.js +148 -0
- package/lib/lucid/LucidIntro.jsx +105 -0
- package/lib/lucid/blind.js +71 -0
- package/lib/lucid/deck.js +32 -0
- package/lib/lucid/fmt.js +139 -0
- package/lib/lucid/sitrep.js +173 -0
- package/lib/plugins/loader.js +93 -0
- package/lib/quoxon/activity.js +104 -0
- package/lib/quoxon/api.js +473 -0
- package/lib/quoxon/app.js +1262 -0
- package/lib/quoxon/capabilities.js +75 -0
- package/lib/quoxon/commands.js +154 -0
- package/lib/quoxon/connect.js +467 -0
- package/lib/quoxon/connectCard.js +28 -0
- package/lib/quoxon/context.js +68 -0
- package/lib/quoxon/copy.js +33 -0
- package/lib/quoxon/demo.fixtures.js +24 -0
- package/lib/quoxon/demo.js +35 -0
- package/lib/quoxon/egg.js +36 -0
- package/lib/quoxon/events.js +458 -0
- package/lib/quoxon/gallery.js +223 -0
- package/lib/quoxon/indicator.js +193 -0
- package/lib/quoxon/intent.js +41 -0
- package/lib/quoxon/keys.js +121 -0
- package/lib/quoxon/main.js +244 -0
- package/lib/quoxon/markdown.js +317 -0
- package/lib/quoxon/model.js +205 -0
- package/lib/quoxon/motion/clock.js +69 -0
- package/lib/quoxon/motion/color.js +35 -0
- package/lib/quoxon/motion/frame.js +14 -0
- package/lib/quoxon/motion/reveal.js +54 -0
- package/lib/quoxon/motion/ring.js +79 -0
- package/lib/quoxon/objects/agent.js +61 -0
- package/lib/quoxon/objects/crystal.js +100 -0
- package/lib/quoxon/objects/fetch.js +170 -0
- package/lib/quoxon/objects/index.js +85 -0
- package/lib/quoxon/objects/ladder.js +71 -0
- package/lib/quoxon/objects/memory.js +44 -0
- package/lib/quoxon/objects/model.js +118 -0
- package/lib/quoxon/objects/proof.js +110 -0
- package/lib/quoxon/objects/skill.js +105 -0
- package/lib/quoxon/objects/view.js +76 -0
- package/lib/quoxon/objects/workflow.js +119 -0
- package/lib/quoxon/observers.js +42 -0
- package/lib/quoxon/onboarding/copy.js +235 -0
- package/lib/quoxon/onboarding/credential.js +95 -0
- package/lib/quoxon/onboarding/detect.js +22 -0
- package/lib/quoxon/onboarding/finish.js +119 -0
- package/lib/quoxon/onboarding/index.js +67 -0
- package/lib/quoxon/onboarding/installRunner.js +225 -0
- package/lib/quoxon/onboarding/intent.js +53 -0
- package/lib/quoxon/onboarding/journey.js +73 -0
- package/lib/quoxon/onboarding/preflight.js +142 -0
- package/lib/quoxon/onboarding/runner.js +58 -0
- package/lib/quoxon/onboarding/view.js +625 -0
- package/lib/quoxon/print.js +94 -0
- package/lib/quoxon/qart.js +379 -0
- package/lib/quoxon/qglyph.js +143 -0
- package/lib/quoxon/qhello.js +58 -0
- package/lib/quoxon/qmotion.js +111 -0
- package/lib/quoxon/render.js +648 -0
- package/lib/quoxon/roster.js +221 -0
- package/lib/quoxon/route.js +38 -0
- package/lib/quoxon/rules.js +130 -0
- package/lib/quoxon/session.js +68 -0
- package/lib/quoxon/skills.js +30 -0
- package/lib/quoxon/slash/compact.js +90 -0
- package/lib/quoxon/slash/ledger.js +83 -0
- package/lib/quoxon/slash/listView.js +28 -0
- package/lib/quoxon/slash/palette.js +104 -0
- package/lib/quoxon/slash/paletteController.js +59 -0
- package/lib/quoxon/slash/panels.js +101 -0
- package/lib/quoxon/slash/picker.js +38 -0
- package/lib/quoxon/slash/restart.js +144 -0
- package/lib/quoxon/slash/resume.js +59 -0
- package/lib/quoxon/slash/settings.js +50 -0
- package/lib/quoxon/sound/index.js +186 -0
- package/lib/quoxon/sound/mapping.js +119 -0
- package/lib/quoxon/sound/output.js +52 -0
- package/lib/quoxon/sound/synth.js +122 -0
- package/lib/quoxon/term.js +106 -0
- package/lib/quoxon/textBuffer.js +242 -0
- package/lib/quoxon/theme.js +253 -0
- package/lib/quoxon/transcript.js +150 -0
- package/lib/quoxon/web/qface.js +81 -0
- package/lib/quoxon/welcome.js +52 -0
- package/lib/tui/app.jsx +289 -0
- package/lib/tui/components/Breadcrumb.jsx +42 -0
- package/lib/tui/components/ChatInput.jsx +68 -0
- package/lib/tui/components/Confirm.jsx +32 -0
- package/lib/tui/components/DataTable.jsx +270 -0
- package/lib/tui/components/DetailPanel.jsx +241 -0
- package/lib/tui/components/HelpOverlay.jsx +67 -0
- package/lib/tui/components/Layout.jsx +129 -0
- package/lib/tui/components/Palette.jsx +141 -0
- package/lib/tui/components/SearchInput.jsx +125 -0
- package/lib/tui/components/Sparkline.jsx +55 -0
- package/lib/tui/components/StatusBar.jsx +133 -0
- package/lib/tui/components/Toast.jsx +52 -0
- package/lib/tui/hooks/useApi.js +44 -0
- package/lib/tui/hooks/useHealth.js +90 -0
- package/lib/tui/hooks/usePolledApi.js +86 -0
- package/lib/tui/hooks/useTerminalSize.js +46 -0
- package/lib/tui/index.js +56 -0
- package/lib/tui/intro/Intro.jsx +332 -0
- package/lib/tui/jsx-loader.js +32 -0
- package/lib/tui/keys.js +87 -0
- package/lib/tui/registry.js +49 -0
- package/lib/tui/theme.js +103 -0
- package/lib/tui/views/AdminView.jsx +398 -0
- package/lib/tui/views/AgentView.jsx +204 -0
- package/lib/tui/views/BackupView.jsx +225 -0
- package/lib/tui/views/BrainView.jsx +223 -0
- package/lib/tui/views/ChatView.jsx +468 -0
- package/lib/tui/views/FilesView.jsx +218 -0
- package/lib/tui/views/FleetView.jsx +234 -0
- package/lib/tui/views/GovernanceView.jsx +453 -0
- package/lib/tui/views/HealthView.jsx +216 -0
- package/lib/tui/views/InboxView.jsx +228 -0
- package/lib/tui/views/LucidDeck.jsx +727 -0
- package/lib/tui/views/MainMenu.jsx +123 -0
- package/lib/tui/views/MemoryView.jsx +377 -0
- package/lib/tui/views/ObjectivesView.jsx +230 -0
- package/lib/tui/views/OrgView.jsx +229 -0
- package/lib/tui/views/ToolView.jsx +221 -0
- package/lib/tui/views/VaultView.jsx +422 -0
- package/lib/tui/views/WorkflowView.jsx +329 -0
- package/package.json +72 -4
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Systemd USER unit installer for the resident supervisor, Option A
|
|
3
|
+
* persistence (DEVSUITE-DEVMODE follow-on, quox#453 phase 15 follow-on).
|
|
4
|
+
*
|
|
5
|
+
* A resident's `quox dev supervisor run` proved out one-shot passes; this
|
|
6
|
+
* module is the delivery half: a `Restart=always` systemd --user unit so the
|
|
7
|
+
* runner answers its slot channel with no session open and comes back after
|
|
8
|
+
* a crash, a logout, or a reboot (the last of those needs `loginctl
|
|
9
|
+
* enable-linger`, see installSupervisorService below).
|
|
10
|
+
*
|
|
11
|
+
* GOVERNANCE NOTE: installing this unit is deliberately NOT the autonomy
|
|
12
|
+
* grant. The unit only ever answers a message when the human-gated
|
|
13
|
+
* server-side switch (`quox dev supervisor enable`, requireHumanAdmin on the
|
|
14
|
+
* collector) is on, see supervisorEngine.js's kill-switch check in
|
|
15
|
+
* runPollOnce. A resident may install its own service on its own host (the
|
|
16
|
+
* scoped self-service pattern), while a human still decides whether it may
|
|
17
|
+
* act. Installing the unit and being allowed to act are two different
|
|
18
|
+
* switches on purpose.
|
|
19
|
+
*
|
|
20
|
+
* Every systemctl/loginctl call goes through one injectable `execFn` seam
|
|
21
|
+
* (see realExecFn below) so this is fully testable without touching the
|
|
22
|
+
* real system: tests pass a fake execFn and never spawn systemctl.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { execFile } from 'node:child_process';
|
|
26
|
+
import { promisify } from 'node:util';
|
|
27
|
+
import { homedir } from 'node:os';
|
|
28
|
+
import { join } from 'node:path';
|
|
29
|
+
|
|
30
|
+
const execFileAsync = promisify(execFile);
|
|
31
|
+
|
|
32
|
+
export const SERVICE_NAME = 'quox-resident-supervisor.service';
|
|
33
|
+
export const DEFAULT_RESTART_SEC = 10;
|
|
34
|
+
|
|
35
|
+
// Item 2 (found by the acceptance owner driving this on a real host,
|
|
36
|
+
// 2026-08-23): `systemctl --user enable --now` reporting success only
|
|
37
|
+
// proves systemd ACCEPTED the unit, not that it stayed up. A unit whose
|
|
38
|
+
// ExecStart dies immediately (the item 1 slot bug is exactly this) is
|
|
39
|
+
// already crash-looping under Restart=always by the time enable returns.
|
|
40
|
+
// Give it a few seconds, then read the live state back through the same
|
|
41
|
+
// injectable execFn seam before calling install a success.
|
|
42
|
+
export const DEFAULT_VERIFY_DELAY_MS = 3000;
|
|
43
|
+
|
|
44
|
+
export function defaultUnitDir() {
|
|
45
|
+
return join(homedir(), '.config', 'systemd', 'user');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function defaultUnitPath() {
|
|
49
|
+
return join(defaultUnitDir(), SERVICE_NAME);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Build the unit file content. Pure string building, no I/O, so it is
|
|
54
|
+
* trivially snapshot-testable.
|
|
55
|
+
*
|
|
56
|
+
* `env`: extra `Environment=KEY=VALUE` lines, baked in at install time
|
|
57
|
+
* (item 1, live outage 2026-08-23). A systemd unit inherits none of the
|
|
58
|
+
* caller's shell environment, so this is how a resident's reach-back URLs
|
|
59
|
+
* (QUOX_URL/QUOX_AUTH_URL/QUOX_COLLECTOR_URL/QUOX_INSECURE) survive into a
|
|
60
|
+
* unit that runs with no login shell present. A value containing a newline
|
|
61
|
+
* or a quote would break the unit file's own line-based format, or the
|
|
62
|
+
* shell-like word-splitting systemd applies to `Environment=`, so it is
|
|
63
|
+
* refused outright rather than escaped: an unrenderable value is a defect
|
|
64
|
+
* in the caller, not something this function should paper over.
|
|
65
|
+
*/
|
|
66
|
+
export function buildUnitFile({ quoxBin, extraArgs = [], restartSec = DEFAULT_RESTART_SEC, home = homedir(), env = {} } = {}) {
|
|
67
|
+
const bin = quoxBin || 'quox';
|
|
68
|
+
const argsSuffix = extraArgs.length ? ` ${extraArgs.join(' ')}` : '';
|
|
69
|
+
const envLines = Object.entries(env || {}).map(([key, value]) => {
|
|
70
|
+
const v = String(value);
|
|
71
|
+
if (/[\n"']/.test(v)) {
|
|
72
|
+
throw new Error(
|
|
73
|
+
`buildUnitFile: refusing to bake ${key} into the unit: its value contains a newline or a quote character `
|
|
74
|
+
+ `(value withheld from this message). Fix the source value rather than escaping it.`
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
return `Environment=${key}=${v}`;
|
|
78
|
+
});
|
|
79
|
+
return [
|
|
80
|
+
'[Unit]',
|
|
81
|
+
'Description=Quox resident supervisor (answers the slot channel headlessly, no session open)',
|
|
82
|
+
'After=network-online.target',
|
|
83
|
+
'Wants=network-online.target',
|
|
84
|
+
'',
|
|
85
|
+
'[Service]',
|
|
86
|
+
'Type=simple',
|
|
87
|
+
`ExecStart=${bin} dev supervisor run --service${argsSuffix}`,
|
|
88
|
+
'Restart=always',
|
|
89
|
+
`RestartSec=${restartSec}`,
|
|
90
|
+
`Environment=HOME=${home}`,
|
|
91
|
+
...envLines,
|
|
92
|
+
'',
|
|
93
|
+
'[Install]',
|
|
94
|
+
'WantedBy=default.target',
|
|
95
|
+
'',
|
|
96
|
+
].join('\n');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Real systemctl/loginctl runner (the non-test default). Never throws for a
|
|
101
|
+
* nonzero exit: callers read `.ok`/`.stderr`, so a failed `enable-linger`
|
|
102
|
+
* (e.g. no permission) is data, not a crash.
|
|
103
|
+
*/
|
|
104
|
+
export async function realExecFn(cmd, args) {
|
|
105
|
+
return execFileAsync(cmd, args, { timeout: 15000 });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Parses `systemctl --user show <unit> -p A,B,C` stdout ("KEY=value" per line) into an object. */
|
|
109
|
+
function parseSystemctlShow(stdout) {
|
|
110
|
+
const props = {};
|
|
111
|
+
for (const line of String(stdout || '').split('\n')) {
|
|
112
|
+
const idx = line.indexOf('=');
|
|
113
|
+
if (idx === -1) continue;
|
|
114
|
+
props[line.slice(0, idx).trim()] = line.slice(idx + 1).trim();
|
|
115
|
+
}
|
|
116
|
+
return props;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function runExec(execFn, cmd, args) {
|
|
120
|
+
try {
|
|
121
|
+
const res = await execFn(cmd, args);
|
|
122
|
+
return { ok: true, code: 0, stdout: (res && res.stdout) || '', stderr: (res && res.stderr) || '' };
|
|
123
|
+
} catch (err) {
|
|
124
|
+
return {
|
|
125
|
+
ok: false,
|
|
126
|
+
code: err && err.code != null ? err.code : 1,
|
|
127
|
+
stdout: (err && err.stdout) || '',
|
|
128
|
+
stderr: (err && (err.stderr || err.message)) || String(err),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Write the unit, reload the daemon, enable + start it now, and attempt
|
|
135
|
+
* linger. Every step's outcome is reported back rather than thrown, because
|
|
136
|
+
* a partial success (unit written, enabled, but linger refused) is the
|
|
137
|
+
* single most important case this reports honestly: the service DOES start
|
|
138
|
+
* now, but will not survive a reboot or logout until linger is granted.
|
|
139
|
+
*/
|
|
140
|
+
export async function installSupervisorService({
|
|
141
|
+
execFn,
|
|
142
|
+
writeFileFn,
|
|
143
|
+
mkdirFn,
|
|
144
|
+
unitPath = defaultUnitPath(),
|
|
145
|
+
unitDir = defaultUnitDir(),
|
|
146
|
+
unitContent,
|
|
147
|
+
user,
|
|
148
|
+
// Injectable so tests never wait the real delay. Defaults to a real sleep
|
|
149
|
+
// outside tests, same seam discipline as execFn/writeFileFn/mkdirFn.
|
|
150
|
+
sleepFn = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
151
|
+
verifyDelayMs = DEFAULT_VERIFY_DELAY_MS,
|
|
152
|
+
}) {
|
|
153
|
+
mkdirFn(unitDir, { recursive: true });
|
|
154
|
+
writeFileFn(unitPath, unitContent);
|
|
155
|
+
|
|
156
|
+
const reload = await runExec(execFn, 'systemctl', ['--user', 'daemon-reload']);
|
|
157
|
+
const enable = await runExec(execFn, 'systemctl', ['--user', 'enable', '--now', SERVICE_NAME]);
|
|
158
|
+
const linger = await runExec(execFn, 'loginctl', ['enable-linger', user]);
|
|
159
|
+
|
|
160
|
+
// Item 2: post-start verification. Only meaningful once `enable --now`
|
|
161
|
+
// itself reported success — if enable failed outright there is nothing
|
|
162
|
+
// running to crash-loop, and the enable_error above already says so.
|
|
163
|
+
let verified = false;
|
|
164
|
+
let active = false;
|
|
165
|
+
let restartCount = null;
|
|
166
|
+
let crashLoop = false;
|
|
167
|
+
let crashJournal = null;
|
|
168
|
+
if (enable.ok) {
|
|
169
|
+
await sleepFn(verifyDelayMs);
|
|
170
|
+
const show = await runExec(execFn, 'systemctl', ['--user', 'show', SERVICE_NAME, '-p', 'NRestarts,ActiveState,SubState']);
|
|
171
|
+
verified = true;
|
|
172
|
+
if (show.ok) {
|
|
173
|
+
const props = parseSystemctlShow(show.stdout);
|
|
174
|
+
active = props.ActiveState === 'active';
|
|
175
|
+
const n = Number(props.NRestarts);
|
|
176
|
+
restartCount = Number.isFinite(n) ? n : null;
|
|
177
|
+
// Restart=always means one crash silently reads as "started fine"
|
|
178
|
+
// unless something checks back: any restart already counted, or a
|
|
179
|
+
// state that isn't active, in our short observation window is a
|
|
180
|
+
// crash loop, not a success, however cheerfully `enable --now` read.
|
|
181
|
+
crashLoop = !active || (restartCount != null && restartCount > 0);
|
|
182
|
+
} else {
|
|
183
|
+
// Could not read the live state back at all: never claim success on
|
|
184
|
+
// silence — same "an error body is not data" discipline as the rest
|
|
185
|
+
// of this codebase's proof-before-claim rule.
|
|
186
|
+
crashLoop = true;
|
|
187
|
+
}
|
|
188
|
+
if (crashLoop) {
|
|
189
|
+
const journal = await runExec(execFn, 'journalctl', ['--user', '-u', SERVICE_NAME, '-n', '20', '--no-pager']);
|
|
190
|
+
crashJournal = journal.ok ? journal.stdout : null;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
unit_path: unitPath,
|
|
196
|
+
daemon_reloaded: reload.ok,
|
|
197
|
+
daemon_reload_error: reload.ok ? null : (reload.stderr || `exit ${reload.code}`),
|
|
198
|
+
enabled: enable.ok,
|
|
199
|
+
enable_error: enable.ok ? null : (enable.stderr || `exit ${enable.code}`),
|
|
200
|
+
linger_enabled: linger.ok,
|
|
201
|
+
linger_error: linger.ok ? null : (linger.stderr || `exit ${linger.code}`),
|
|
202
|
+
verified,
|
|
203
|
+
active,
|
|
204
|
+
restart_count: restartCount,
|
|
205
|
+
crash_loop: crashLoop,
|
|
206
|
+
crash_journal: crashJournal,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export async function uninstallSupervisorService({
|
|
211
|
+
execFn,
|
|
212
|
+
existsFn,
|
|
213
|
+
unlinkFn,
|
|
214
|
+
unitPath = defaultUnitPath(),
|
|
215
|
+
}) {
|
|
216
|
+
const disable = await runExec(execFn, 'systemctl', ['--user', 'disable', '--now', SERVICE_NAME]);
|
|
217
|
+
|
|
218
|
+
let removed = false;
|
|
219
|
+
if (existsFn(unitPath)) {
|
|
220
|
+
unlinkFn(unitPath);
|
|
221
|
+
removed = true;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const reload = await runExec(execFn, 'systemctl', ['--user', 'daemon-reload']);
|
|
225
|
+
|
|
226
|
+
return {
|
|
227
|
+
disabled: disable.ok,
|
|
228
|
+
disable_error: disable.ok ? null : (disable.stderr || `exit ${disable.code}`),
|
|
229
|
+
unit_removed: removed,
|
|
230
|
+
daemon_reloaded: reload.ok,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Read-only install/enable/active/linger report for `quox dev supervisor
|
|
236
|
+
* status`. Never throws: an unreadable systemctl/loginctl reports as
|
|
237
|
+
* false/unknown rather than crashing a status check.
|
|
238
|
+
*/
|
|
239
|
+
export async function getServiceInstallStatus({
|
|
240
|
+
execFn,
|
|
241
|
+
existsFn,
|
|
242
|
+
unitPath = defaultUnitPath(),
|
|
243
|
+
user,
|
|
244
|
+
}) {
|
|
245
|
+
const installed = existsFn(unitPath);
|
|
246
|
+
const enabledRes = await runExec(execFn, 'systemctl', ['--user', 'is-enabled', SERVICE_NAME]);
|
|
247
|
+
const activeRes = await runExec(execFn, 'systemctl', ['--user', 'is-active', SERVICE_NAME]);
|
|
248
|
+
const lingerRes = await runExec(execFn, 'loginctl', ['show-user', user, '--property=Linger', '--value']);
|
|
249
|
+
|
|
250
|
+
return {
|
|
251
|
+
installed,
|
|
252
|
+
enabled: enabledRes.stdout.trim() === 'enabled',
|
|
253
|
+
active: activeRes.stdout.trim() === 'active',
|
|
254
|
+
linger: lingerRes.stdout.trim().toLowerCase() === 'yes',
|
|
255
|
+
};
|
|
256
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI version + drift resolution — shared so `quox --version` (bin/quox.js)
|
|
3
|
+
* and `quox doctor` (commands/doctor.js) report the SAME facts and can never
|
|
4
|
+
* disagree about which build is running. Closes quoxai/quox#610.
|
|
5
|
+
*
|
|
6
|
+
* Every git call is fully guarded (stderr discarded, failure swallowed) so
|
|
7
|
+
* version/drift resolution can never break a command: a plain-package install
|
|
8
|
+
* with no .git, a missing git binary, or a detached worktree all degrade to a
|
|
9
|
+
* sensible fallback rather than throwing.
|
|
10
|
+
*
|
|
11
|
+
* Note on cost: bin/quox.js keeps its argv guard at the CALL SITE (it only
|
|
12
|
+
* calls resolveCliVersion() when --version/-V is actually present) so normal
|
|
13
|
+
* commands stay zero-cost. doctor calls both freely — the user explicitly ran
|
|
14
|
+
* a diagnostic, so spawning git is expected there.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { execFileSync } from 'node:child_process';
|
|
18
|
+
import { fileURLToPath } from 'node:url';
|
|
19
|
+
import { dirname, resolve } from 'node:path';
|
|
20
|
+
|
|
21
|
+
export const BASE_VERSION = '0.2.0';
|
|
22
|
+
|
|
23
|
+
// The CLI source root (…/cli). version.js lives at cli/lib/core/, so two
|
|
24
|
+
// levels up is the tree whose HEAD identifies the running build.
|
|
25
|
+
const CLI_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
26
|
+
|
|
27
|
+
function git(args) {
|
|
28
|
+
return execFileSync('git', ['-C', CLI_ROOT, ...args], {
|
|
29
|
+
encoding: 'utf8',
|
|
30
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
31
|
+
}).trim();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Human version string: "0.1.0 (7bac4f67, session/vulcan)" — or bare
|
|
36
|
+
* "0.1.0" when git facts are unavailable. Unlike the drift check this never
|
|
37
|
+
* needs origin/main, so it works on a shallow or rem-less clone.
|
|
38
|
+
*/
|
|
39
|
+
export function resolveCliVersion() {
|
|
40
|
+
try {
|
|
41
|
+
const sha = git(['rev-parse', '--short', 'HEAD']);
|
|
42
|
+
if (!sha) return BASE_VERSION;
|
|
43
|
+
let ref = '';
|
|
44
|
+
try { ref = git(['rev-parse', '--abbrev-ref', 'HEAD']); } catch { /* detached HEAD / no ref */ }
|
|
45
|
+
const label = ref && ref !== 'HEAD' ? ref : 'detached';
|
|
46
|
+
return `${BASE_VERSION} (${sha}, ${label})`;
|
|
47
|
+
} catch {
|
|
48
|
+
return BASE_VERSION;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Drift of the running worktree vs the LAST-FETCHED origin/main.
|
|
54
|
+
*
|
|
55
|
+
* Deliberately does NOT fetch: a health check must not hang on a slow remote
|
|
56
|
+
* or block on credentials. The count therefore reflects the last fetch, which
|
|
57
|
+
* the detail line states plainly. This mirrors scripts/quox-cli-refresh.sh's
|
|
58
|
+
* `git rev-list --count HEAD..origin/main` so doctor and the refresh tool agree
|
|
59
|
+
* (#607 shipped the refresh side).
|
|
60
|
+
*
|
|
61
|
+
* @returns {{status:'ok'|'stale'|'unknown'|'skip', detail:string}}
|
|
62
|
+
* ok — HEAD == origin/main
|
|
63
|
+
* stale — behind origin/main; nudge quox-cli-refresh
|
|
64
|
+
* unknown — no origin/main ref locally (never fetched here)
|
|
65
|
+
* skip — not a git worktree (plain-package install)
|
|
66
|
+
*/
|
|
67
|
+
export function checkCliDrift() {
|
|
68
|
+
let head;
|
|
69
|
+
try {
|
|
70
|
+
head = git(['rev-parse', 'HEAD']);
|
|
71
|
+
} catch {
|
|
72
|
+
return { status: 'skip', detail: 'not a git worktree' };
|
|
73
|
+
}
|
|
74
|
+
let remote;
|
|
75
|
+
try {
|
|
76
|
+
remote = git(['rev-parse', 'origin/main']);
|
|
77
|
+
} catch {
|
|
78
|
+
return { status: 'unknown', detail: 'no origin/main ref (never fetched here)' };
|
|
79
|
+
}
|
|
80
|
+
if (head === remote) return { status: 'ok', detail: 'up to date with origin/main' };
|
|
81
|
+
let behind = '?';
|
|
82
|
+
try { behind = git(['rev-list', '--count', 'HEAD..origin/main']); } catch { /* keep ? */ }
|
|
83
|
+
return {
|
|
84
|
+
status: 'stale',
|
|
85
|
+
detail: `${behind} commit(s) behind origin/main (run quox-cli-refresh) — reflects last fetch`,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Website admin session for owner-only licence verbs (GRIPE-715 phase 2).
|
|
3
|
+
*
|
|
4
|
+
* `quox licence reissue` and `quox licence catalogue-sign` talk to the quox.ai
|
|
5
|
+
* WEBSITE admin API, not to this instance, so they need their own credential,
|
|
6
|
+
* separate from the instance profile in config.json.
|
|
7
|
+
*
|
|
8
|
+
* What is stored: per website profile, the base URL, the admin username and
|
|
9
|
+
* the website session cookie (`quox.sid`, 24h). The password is used once for
|
|
10
|
+
* POST /api/admin/login and never written anywhere. The file is 0600. Nothing
|
|
11
|
+
* here prints the cookie.
|
|
12
|
+
*
|
|
13
|
+
* File: ~/.config/quox/website-sessions.json (QUOX_WEBSITE_SESSION_FILE overrides,
|
|
14
|
+
* used by tests).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync, renameSync } from 'node:fs';
|
|
18
|
+
import { dirname, join } from 'node:path';
|
|
19
|
+
import { homedir } from 'node:os';
|
|
20
|
+
import { QuoxError, QuoxValidationError } from './errors.js';
|
|
21
|
+
|
|
22
|
+
export const DEFAULT_WEBSITE_URL = 'https://quox.ai';
|
|
23
|
+
export const DEFAULT_WEBSITE_PROFILE = 'default';
|
|
24
|
+
const SESSION_COOKIE = 'quox.sid';
|
|
25
|
+
|
|
26
|
+
export function sessionFilePath() {
|
|
27
|
+
return process.env.QUOX_WEBSITE_SESSION_FILE || join(homedir(), '.config', 'quox', 'website-sessions.json');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function readStore(file = sessionFilePath()) {
|
|
31
|
+
if (!existsSync(file)) return { profiles: {} };
|
|
32
|
+
try {
|
|
33
|
+
const parsed = JSON.parse(readFileSync(file, 'utf8'));
|
|
34
|
+
return parsed && typeof parsed.profiles === 'object' ? parsed : { profiles: {} };
|
|
35
|
+
} catch {
|
|
36
|
+
return { profiles: {} };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function writeStore(store, file = sessionFilePath()) {
|
|
41
|
+
mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
|
|
42
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
43
|
+
writeFileSync(tmp, JSON.stringify(store, null, 2), { mode: 0o600 });
|
|
44
|
+
chmodSync(tmp, 0o600);
|
|
45
|
+
renameSync(tmp, file);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function normaliseWebsiteUrl(url) {
|
|
49
|
+
let u;
|
|
50
|
+
try { u = new URL(url); } catch { throw new QuoxValidationError(`Invalid website URL: ${url}`); }
|
|
51
|
+
const local = u.hostname === 'localhost' || u.hostname === '127.0.0.1';
|
|
52
|
+
if (u.protocol !== 'https:' && !local) {
|
|
53
|
+
throw new QuoxValidationError('The website URL must be https (the session cookie is a credential); http is allowed only for localhost');
|
|
54
|
+
}
|
|
55
|
+
return `${u.protocol}//${u.host}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function getWebsiteSession(profile = DEFAULT_WEBSITE_PROFILE) {
|
|
59
|
+
return readStore().profiles[profile] || null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function saveWebsiteSession(profile, session) {
|
|
63
|
+
const store = readStore();
|
|
64
|
+
store.profiles[profile] = session;
|
|
65
|
+
writeStore(store);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function clearWebsiteSession(profile) {
|
|
69
|
+
const store = readStore();
|
|
70
|
+
const had = !!store.profiles[profile];
|
|
71
|
+
delete store.profiles[profile];
|
|
72
|
+
writeStore(store);
|
|
73
|
+
return had;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Pull `quox.sid=<value>` out of the Set-Cookie header(s). */
|
|
77
|
+
export function extractSessionCookie(headers) {
|
|
78
|
+
const list = typeof headers.getSetCookie === 'function'
|
|
79
|
+
? headers.getSetCookie()
|
|
80
|
+
: [headers.get('set-cookie')].filter(Boolean);
|
|
81
|
+
for (const line of list) {
|
|
82
|
+
for (const part of String(line).split(/,(?=\s*[^;,=\s]+=)/)) {
|
|
83
|
+
const pair = part.trim().split(';')[0];
|
|
84
|
+
if (pair.startsWith(`${SESSION_COOKIE}=`)) return pair;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function readJson(res) {
|
|
91
|
+
const text = await res.text();
|
|
92
|
+
try { return text ? JSON.parse(text) : {}; } catch { return { error: text.slice(0, 200) }; }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Log in to the website admin API. Returns the session to save; never the password. */
|
|
96
|
+
export async function websiteLogin({ url, username, password, fetchImpl = globalThis.fetch }) {
|
|
97
|
+
const base = normaliseWebsiteUrl(url);
|
|
98
|
+
const res = await fetchImpl(`${base}/api/admin/login`, {
|
|
99
|
+
method: 'POST',
|
|
100
|
+
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
101
|
+
body: JSON.stringify({ username, password }),
|
|
102
|
+
redirect: 'manual',
|
|
103
|
+
});
|
|
104
|
+
const body = await readJson(res);
|
|
105
|
+
if (!res.ok) throw new QuoxError(`Website login failed (HTTP ${res.status}): ${body.error || 'no detail'}`, 3);
|
|
106
|
+
const cookie = extractSessionCookie(res.headers);
|
|
107
|
+
if (!cookie) throw new QuoxError('Website login returned no session cookie; nothing saved', 3);
|
|
108
|
+
return {
|
|
109
|
+
url: base,
|
|
110
|
+
username: body.username || username,
|
|
111
|
+
cookie,
|
|
112
|
+
must_change_password: !!body.mustChangePassword,
|
|
113
|
+
saved_at: new Date().toISOString(),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* POST to a website admin route with the saved session. Checks the status
|
|
119
|
+
* before trusting the body; a 401 means the 24h session lapsed.
|
|
120
|
+
*/
|
|
121
|
+
export async function websitePost(profile, path, body, { fetchImpl = globalThis.fetch } = {}) {
|
|
122
|
+
const session = getWebsiteSession(profile);
|
|
123
|
+
if (!session?.cookie) {
|
|
124
|
+
throw new QuoxError(`No website session for profile "${profile}". Run: quox licence website-login${profile === DEFAULT_WEBSITE_PROFILE ? '' : ` --website-profile ${profile}`}`, 3);
|
|
125
|
+
}
|
|
126
|
+
let res;
|
|
127
|
+
try {
|
|
128
|
+
res = await fetchImpl(`${session.url}${path}`, {
|
|
129
|
+
method: 'POST',
|
|
130
|
+
headers: { 'Content-Type': 'application/json', Accept: 'application/json', Cookie: session.cookie },
|
|
131
|
+
body: JSON.stringify(body || {}),
|
|
132
|
+
redirect: 'manual',
|
|
133
|
+
});
|
|
134
|
+
} catch (err) {
|
|
135
|
+
throw new QuoxError(`Could not reach the website admin API at ${session.url}: ${err.message}`, 7);
|
|
136
|
+
}
|
|
137
|
+
const data = await readJson(res);
|
|
138
|
+
if (res.status === 401) {
|
|
139
|
+
throw new QuoxError(`Website session expired or invalid (HTTP 401 from ${session.url}). Run: quox licence website-login`, 3);
|
|
140
|
+
}
|
|
141
|
+
if (!res.ok) {
|
|
142
|
+
const err = new QuoxError(`Website API ${path} failed (HTTP ${res.status}${data.code ? ` ${data.code}` : ''}): ${data.error || 'no detail'}`, res.status === 403 ? 4 : 1);
|
|
143
|
+
err.status = res.status;
|
|
144
|
+
err.body = data;
|
|
145
|
+
throw err;
|
|
146
|
+
}
|
|
147
|
+
return data;
|
|
148
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LucidIntro: the ~1.2s startup for `quox lucid deck`.
|
|
3
|
+
*
|
|
4
|
+
* Frames (50ms): the QUOX block wordmark is revealed by a gradient sweep, the
|
|
5
|
+
* ◉ LUCID mark fades in under it, then "tuning in…" resolves into
|
|
6
|
+
* "◉ LUCID │ N sources live · M need you" once the first fetch has landed.
|
|
7
|
+
* The fetch is owned by the deck and runs in parallel, never gated on this.
|
|
8
|
+
* onDone fires once: at the natural end, on a hard 2.5s cap, or on a key
|
|
9
|
+
* (the deck calls skip via the `skipSignal` prop).
|
|
10
|
+
*/
|
|
11
|
+
import React, { useState, useEffect, useRef } from 'react';
|
|
12
|
+
import { Box, Text } from 'ink';
|
|
13
|
+
import chalk from 'chalk';
|
|
14
|
+
import { BLOCK_FONT, gradientColor } from '../tui/intro/Intro.jsx';
|
|
15
|
+
import { PALETTE, STATE_HEX } from './fmt.js';
|
|
16
|
+
|
|
17
|
+
export const FRAME_MS = 50;
|
|
18
|
+
const SWEEP_FRAMES = 12;
|
|
19
|
+
const MIN_FRAMES = 24; // 1.2s
|
|
20
|
+
const MAX_FRAMES = 50; // 2.5s cap if the fetch never lands
|
|
21
|
+
|
|
22
|
+
const LETTERS = ['Q', 'U', 'O', 'X'];
|
|
23
|
+
const rgb = (c, s) => chalk.rgb(c[0], c[1], c[2])(s);
|
|
24
|
+
|
|
25
|
+
function titleRow(row, frame, colsRevealed) {
|
|
26
|
+
let out = ''; let col = 0;
|
|
27
|
+
const total = LETTERS.reduce((n, ch) => n + BLOCK_FONT[ch][0].length + 1, 0);
|
|
28
|
+
for (const ch of LETTERS) {
|
|
29
|
+
const g = BLOCK_FONT[ch][row];
|
|
30
|
+
for (let i = 0; i < g.length; i++) {
|
|
31
|
+
const x = col + i;
|
|
32
|
+
const lit = g[i] !== ' ';
|
|
33
|
+
if (x > colsRevealed) { out += ' '; continue; }
|
|
34
|
+
if (!lit) { out += ' '; continue; }
|
|
35
|
+
const edge = colsRevealed - x < 2 && colsRevealed < total;
|
|
36
|
+
out += edge ? chalk.white('██') : rgb(gradientColor(x / total + frame * 0.02), '██');
|
|
37
|
+
}
|
|
38
|
+
out += ' ';
|
|
39
|
+
col += g.length + 1;
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function LucidIntro({ data, err, onDone, rows = 24 }) {
|
|
45
|
+
const [frame, setFrame] = useState(0);
|
|
46
|
+
const doneRef = useRef(false);
|
|
47
|
+
const readyAt = useRef(null);
|
|
48
|
+
const sit = data && data.situation;
|
|
49
|
+
|
|
50
|
+
if (sit && readyAt.current == null) readyAt.current = frame;
|
|
51
|
+
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
const id = setInterval(() => setFrame((f) => f + 1), FRAME_MS);
|
|
54
|
+
return () => clearInterval(id);
|
|
55
|
+
}, []);
|
|
56
|
+
|
|
57
|
+
const finishAt = readyAt.current != null
|
|
58
|
+
? Math.max(MIN_FRAMES, readyAt.current + 6)
|
|
59
|
+
: (err ? Math.max(MIN_FRAMES, 16) : MAX_FRAMES);
|
|
60
|
+
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
if (frame >= finishAt && !doneRef.current) { doneRef.current = true; onDone(); }
|
|
63
|
+
}, [frame, finishAt, onDone]);
|
|
64
|
+
|
|
65
|
+
const tall = rows >= 16;
|
|
66
|
+
const total = LETTERS.reduce((n, ch) => n + BLOCK_FONT[ch][0].length + 1, 0);
|
|
67
|
+
const revealed = Math.round((Math.min(frame, SWEEP_FRAMES) / SWEEP_FRAMES) * (total + 2)) - 1;
|
|
68
|
+
const markOn = frame >= 6;
|
|
69
|
+
const resolved = sit && frame >= 14 && frame >= (readyAt.current ?? 0) + 2;
|
|
70
|
+
const cov = (sit && sit.cannot_see && sit.cannot_see.coverage) || { live: 0, expected: 0 };
|
|
71
|
+
const dots = '.'.repeat((frame % 3) + 1).padEnd(3, ' ');
|
|
72
|
+
|
|
73
|
+
const mark = [...'◉ LUCID'].map((ch, i) => (
|
|
74
|
+
<Text key={i} bold color={'#' + gradientColor((i / 7) + frame * 0.03).map((v) => v.toString(16).padStart(2, '0')).join('')}>{ch}</Text>
|
|
75
|
+
));
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<Box flexDirection="column" alignItems="center" justifyContent="center" flexGrow={1} paddingY={1}>
|
|
79
|
+
{tall ? (
|
|
80
|
+
<Box flexDirection="column" alignItems="center">
|
|
81
|
+
{Array.from({ length: 5 }, (_, r) => <Text key={r}>{titleRow(r, frame, revealed)}</Text>)}
|
|
82
|
+
</Box>
|
|
83
|
+
) : (
|
|
84
|
+
<Text bold color={PALETTE.brand}>Q U O X</Text>
|
|
85
|
+
)}
|
|
86
|
+
<Box marginTop={1}>{markOn ? mark : <Text> </Text>}</Box>
|
|
87
|
+
<Box marginTop={1}>
|
|
88
|
+
{frame < 10 ? <Text> </Text> : resolved ? (
|
|
89
|
+
<Text>
|
|
90
|
+
<Text bold color={PALETTE.brand}>◉ LUCID</Text>
|
|
91
|
+
<Text color={PALETTE.dim}> │ </Text>
|
|
92
|
+
<Text color={STATE_HEX.ok}>{cov.live} sources live</Text>
|
|
93
|
+
<Text color={PALETTE.dim}> · </Text>
|
|
94
|
+
<Text bold color={(sit.needs_you || []).length ? STATE_HEX.attention : STATE_HEX.ok}>{(sit.needs_you || []).length} need you</Text>
|
|
95
|
+
</Text>
|
|
96
|
+
) : err && !sit ? (
|
|
97
|
+
<Text color={STATE_HEX.blind}>LUCID unreachable, still trying</Text>
|
|
98
|
+
) : (
|
|
99
|
+
<Text color={PALETTE.muted}>tuning in{dots}</Text>
|
|
100
|
+
)}
|
|
101
|
+
</Box>
|
|
102
|
+
<Box marginTop={1}><Text color={PALETTE.dim}>any key to skip</Text></Box>
|
|
103
|
+
</Box>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `quox lucid blind`: what LUCID can and cannot hear, and what does not add up.
|
|
3
|
+
* Pure: renderBlind({ situation, feeds, expected }, { now }) -> string (plain text, no colour,
|
|
4
|
+
* so it pastes anywhere and reads the same in a pipe).
|
|
5
|
+
*
|
|
6
|
+
* A source is live, silent (expected but quiet), failing (speaking, but its own read is
|
|
7
|
+
* failing) or never heard. Contradictions list both sides of each mismatch.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
function ago(ms, now) {
|
|
11
|
+
if (ms == null) return '';
|
|
12
|
+
const s = Math.max(0, Math.floor((now - ms) / 1000));
|
|
13
|
+
if (s < 60) return `${s}s ago`;
|
|
14
|
+
const m = Math.floor(s / 60);
|
|
15
|
+
if (m < 60) return `${m}m ago`;
|
|
16
|
+
const h = Math.floor(m / 60);
|
|
17
|
+
if (h < 24) return `${h}h ago`;
|
|
18
|
+
return `${Math.floor(h / 24)}d ago`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const nameOf = (x) => (typeof x === 'string' ? x : (x && (x.feed || x.name)) || '');
|
|
22
|
+
|
|
23
|
+
/** Rows: { source, state, last, note } sorted worst first. */
|
|
24
|
+
export function blindRows({ situation, feeds = [], expected = [] } = {}, now = Date.now()) {
|
|
25
|
+
const cs = (situation && situation.cannot_see) || {};
|
|
26
|
+
const byFeed = new Map(feeds.map((f) => [f.feed, f]));
|
|
27
|
+
const failing = new Map((cs.failing || []).map((x) => [nameOf(x), x]));
|
|
28
|
+
const silent = new Set((cs.silent || []).map(nameOf));
|
|
29
|
+
const unwired = new Set((cs.unwired || []).map(nameOf));
|
|
30
|
+
const names = new Set([...byFeed.keys(), ...expected, ...failing.keys(), ...silent, ...unwired]);
|
|
31
|
+
const rows = [];
|
|
32
|
+
for (const source of names) {
|
|
33
|
+
if (!source) continue;
|
|
34
|
+
const f = byFeed.get(source);
|
|
35
|
+
const last = f && f.last_heard != null ? ago(f.last_heard, now) : '';
|
|
36
|
+
if (failing.has(source)) rows.push({ rank: 0, source, state: 'failing', last, note: (failing.get(source) || {}).error || (f && f.last_error) || '' });
|
|
37
|
+
else if (silent.has(source)) rows.push({ rank: 1, source, state: 'silent', last, note: 'expected, but gone quiet' });
|
|
38
|
+
else if (unwired.has(source) || !f) rows.push({ rank: 2, source, state: 'never heard', last: '', note: 'expected, has never reported' });
|
|
39
|
+
else rows.push({ rank: 3, source, state: 'live', last, note: '' });
|
|
40
|
+
}
|
|
41
|
+
return rows.sort((a, b) => a.rank - b.rank || a.source.localeCompare(b.source));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function renderBlind(data = {}, { now = Date.now() } = {}) {
|
|
45
|
+
const sit = data.situation || {};
|
|
46
|
+
const cov = (sit.cannot_see && sit.cannot_see.coverage) || null;
|
|
47
|
+
const rows = blindRows(data, now);
|
|
48
|
+
const out = [];
|
|
49
|
+
out.push(`SOURCES${cov ? ` (${cov.live} of ${cov.expected} speaking)` : ''}`);
|
|
50
|
+
if (!rows.length) out.push(' I have not heard from any source yet. That is not "all quiet".');
|
|
51
|
+
else {
|
|
52
|
+
const w = Math.max(6, ...rows.map((r) => r.source.length));
|
|
53
|
+
out.push(` ${'SOURCE'.padEnd(w)} ${'STATE'.padEnd(11)} ${'LAST HEARD'.padEnd(10)} NOTE`);
|
|
54
|
+
for (const r of rows) out.push(` ${r.source.padEnd(w)} ${r.state.padEnd(11)} ${r.last.padEnd(10)} ${r.note}`.trimEnd());
|
|
55
|
+
}
|
|
56
|
+
const stale = (sit.cannot_see && sit.cannot_see.stale) || [];
|
|
57
|
+
if (stale.length) out.push('', `Stale claims (source quiet, so these may be out of date): ${stale.map((s) => nameOf({ name: s.subject })).join(', ')}`);
|
|
58
|
+
|
|
59
|
+
const da = sit.doesnt_add_up || [];
|
|
60
|
+
out.push('', `DOESN'T ADD UP${da.length ? ` (${da.length})` : ''}`);
|
|
61
|
+
if (!da.length) out.push(' Nothing I can see contradicts anything else. I can only compare what the sources tell me.');
|
|
62
|
+
da.forEach((x, i) => {
|
|
63
|
+
out.push(` ${i + 1}. ${x.L0}${x.urgent ? ' [TODAY]' : ''}`);
|
|
64
|
+
out.push(` Claimed ${x.claimed}`);
|
|
65
|
+
out.push(` Observed ${x.observed}`);
|
|
66
|
+
if (x.sources && x.sources.length) out.push(` Sources ${x.sources.join(', ')}`);
|
|
67
|
+
if (x.why) out.push(` Why ${x.why}`);
|
|
68
|
+
if (x.how) out.push(` Settle ${x.how}`);
|
|
69
|
+
});
|
|
70
|
+
return `${out.join('\n')}\n`;
|
|
71
|
+
}
|