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,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The credential step: which AI the agents use, and the key, held in memory only.
|
|
3
|
+
*
|
|
4
|
+
* `requestCredential(provider)` is THE hook for the K1 secure key card (/connect, branch session/quox-connect).
|
|
5
|
+
* Until that merges, the broker below backs it with this view's own masked input. Same rules either way: the
|
|
6
|
+
* secret never enters a transcript, history item, journey file, config file or event stream, and the only
|
|
7
|
+
* thing ever shown or persisted about it is its fingerprint.
|
|
8
|
+
*/
|
|
9
|
+
import { createHash } from 'node:crypto';
|
|
10
|
+
import { CRED_OPTIONS } from './copy.js';
|
|
11
|
+
|
|
12
|
+
export const PROVIDERS = ['chatgpt', 'openai', 'anthropic', 'ollama', 'skip'];
|
|
13
|
+
export const KEY_PROVIDERS = new Set(['openai', 'anthropic']);
|
|
14
|
+
// Sign in with ChatGPT stays visible but unselectable until OpenAI approves the integration.
|
|
15
|
+
export const CHATGPT_ENABLED = false;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Options in display order. Ollama is always offered (B3): "on this machine" when localhost:11434
|
|
19
|
+
* answered, else "on another machine" (listed last), which asks for its LAN URL.
|
|
20
|
+
*/
|
|
21
|
+
export function credentialOptions({ ollama = false, chatgpt = CHATGPT_ENABLED } = {}) {
|
|
22
|
+
// Not detected: "on another machine" goes last, so the familiar numbering (4 = Skip) holds.
|
|
23
|
+
const order = ollama ? PROVIDERS : [...PROVIDERS.filter(p => p !== 'ollama'), 'ollama'];
|
|
24
|
+
return order.map(id => {
|
|
25
|
+
if (id === 'ollama') return { id, ...(ollama ? CRED_OPTIONS.ollama : CRED_OPTIONS.ollamaLan), detected: Boolean(ollama), disabled: false };
|
|
26
|
+
return { id, ...CRED_OPTIONS[id], disabled: id === 'chatgpt' && !chatgpt };
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A typed Ollama URL -> normalised http(s) URL (bare host gets http:// and :11434) | null. */
|
|
31
|
+
export function normaliseOllamaUrl(text) {
|
|
32
|
+
let v = String(text || '').trim();
|
|
33
|
+
if (!v || /\s/.test(v)) return null;
|
|
34
|
+
const bare = !/^[a-z][a-z0-9+.-]*:\/\//i.test(v);
|
|
35
|
+
if (bare) v = `http://${v}`;
|
|
36
|
+
let u;
|
|
37
|
+
try { u = new URL(v); } catch { return null; }
|
|
38
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:') return null;
|
|
39
|
+
if (u.username || u.password || !u.hostname) return null;
|
|
40
|
+
if (bare && !u.port) u.port = '11434';
|
|
41
|
+
return u.toString().replace(/\/$/, '');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const fingerprint = (secret) => 'sha256:' + createHash('sha256').update(String(secret)).digest('hex').slice(0, 8);
|
|
45
|
+
|
|
46
|
+
/** A secret that refuses to serialise: JSON, String() and util.inspect all show a redaction. */
|
|
47
|
+
export function holdSecret(provider, value) {
|
|
48
|
+
let v = String(value);
|
|
49
|
+
const fp = fingerprint(v);
|
|
50
|
+
const redacted = `[${provider} key ${fp}]`;
|
|
51
|
+
return Object.freeze({
|
|
52
|
+
provider,
|
|
53
|
+
fingerprint: fp,
|
|
54
|
+
reveal: () => v, // the ONE accessor: C2c hands it to the new instance's vault API, then drops it
|
|
55
|
+
drop: () => { v = ''; },
|
|
56
|
+
toJSON: () => redacted,
|
|
57
|
+
toString: () => redacted,
|
|
58
|
+
[Symbol.for('nodejs.util.inspect.custom')]: () => redacted
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Broker behind requestCredential(): a single pending request the view renders as a masked card.
|
|
64
|
+
* K1 swap: replace createCredentialBroker().request with the secure card call; the view keeps working.
|
|
65
|
+
*/
|
|
66
|
+
export function createCredentialBroker() {
|
|
67
|
+
let pending = null;
|
|
68
|
+
const listeners = new Set();
|
|
69
|
+
const emit = () => { for (const fn of listeners) fn(pending); };
|
|
70
|
+
return {
|
|
71
|
+
/** requestCredential(provider) -> Promise<held secret | null (cancelled)> */
|
|
72
|
+
request(provider) {
|
|
73
|
+
if (pending) pending.resolve(null);
|
|
74
|
+
return new Promise((resolve) => {
|
|
75
|
+
pending = { provider, resolve: (val) => { pending = null; emit(); resolve(val); } };
|
|
76
|
+
emit();
|
|
77
|
+
});
|
|
78
|
+
},
|
|
79
|
+
submit(value) { if (pending && String(value).length) pending.resolve(holdSecret(pending.provider, value)); },
|
|
80
|
+
cancel() { pending?.resolve(null); },
|
|
81
|
+
get pending() { return pending ? { provider: pending.provider } : null; },
|
|
82
|
+
subscribe(fn) { listeners.add(fn); return () => listeners.delete(fn); }
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* THE hook the view calls: requestCredential(provider) -> Promise<held secret | null>.
|
|
88
|
+
* K1 merge (session/quox-connect): return the secure key card's result here instead of broker.request(provider).
|
|
89
|
+
* Whatever backs it must keep the rules above (memory only, fingerprint the only trace).
|
|
90
|
+
*/
|
|
91
|
+
export function createRequestCredential(broker) {
|
|
92
|
+
return function requestCredential(provider) {
|
|
93
|
+
return broker.request(provider);
|
|
94
|
+
};
|
|
95
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Should bare `quox` open Getting started instead of the chat client?
|
|
3
|
+
*
|
|
4
|
+
* Yes only when nothing says a server exists: no profile is signed in (human or resident), no profile points
|
|
5
|
+
* anywhere but the built-in default URL, no QUOX_URL, and the user did not ask for a specific profile or for
|
|
6
|
+
* the ambient service key. Anyone who has ever configured a server keeps today's honest "Not signed in"
|
|
7
|
+
* message instead (they need `quox login`, not an installer).
|
|
8
|
+
*/
|
|
9
|
+
export const DEFAULT_URL = 'http://localhost:3000';
|
|
10
|
+
|
|
11
|
+
export function needsGettingStarted({ profiles = [], residents = [], profileArg = null, env = process.env, serviceKeyFlag = false, journey = null } = {}) {
|
|
12
|
+
if (profileArg) return false;
|
|
13
|
+
if (env.QUOX_URL) return false;
|
|
14
|
+
if ((serviceKeyFlag || env.QUOX_FORCE_SERVICE_KEY) && env.QUOX_SERVICE_KEY) return false;
|
|
15
|
+
if (residents.length) return false;
|
|
16
|
+
if (profiles.some(p => p.loggedIn)) return false;
|
|
17
|
+
// A journey this guide started (install or finish under way) keeps it here, even after the
|
|
18
|
+
// finish step pointed the profile at the new instance: nobody is signed in yet.
|
|
19
|
+
if (journey?.installStage || journey?.step === 'owner' || journey?.step === 'finishing') return true;
|
|
20
|
+
if (profiles.some(p => p.url && p.url.replace(/\/+$/, '') !== DEFAULT_URL)) return false;
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* C2c: finishing a fresh install from the CLI (FIRST_RUN_JOURNEY.md steps 6, 7 and 9).
|
|
3
|
+
*
|
|
4
|
+
* createOwner POST /api/setup/admin the instance's own first-owner route (refuses once an
|
|
5
|
+
* owner exists; creates user, default org, membership)
|
|
6
|
+
* signIn POST /api/auth/login via core/auth.js login(): the profile stores the login
|
|
7
|
+
* token in its existing format. The password is never stored.
|
|
8
|
+
* storeCredential connect.js connectFlow server-side test, then the governed vault write as the
|
|
9
|
+
* org default (POST /api/orgs/:org/vault, audited, label
|
|
10
|
+
* "<Name> …last4"). The held key is dropped right after.
|
|
11
|
+
* loginLink POST /api/auth/login-link a 10 minute, single-use, user+org bound sign-in link,
|
|
12
|
+
* joined to the dashboard URL the installer printed.
|
|
13
|
+
*
|
|
14
|
+
* The CLI talks to the services the way every other command does: profile url https://localhost:3000,
|
|
15
|
+
* services derived to their loopback ports (auth 127.0.0.1:3101), so the self-signed edge certificate
|
|
16
|
+
* never meets the CLI. Only the browser sees it, through the link.
|
|
17
|
+
*/
|
|
18
|
+
import { randomBytes, randomInt } from 'node:crypto';
|
|
19
|
+
import { connectFlow } from '../connect.js';
|
|
20
|
+
|
|
21
|
+
export const LOCAL_PROFILE_URL = 'https://localhost:3000';
|
|
22
|
+
|
|
23
|
+
// The auth service's rules (services/auth/utils/passwordValidation.js).
|
|
24
|
+
const SPECIAL = /[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?`~]/;
|
|
25
|
+
export function passwordProblem(pw) {
|
|
26
|
+
const p = String(pw ?? '');
|
|
27
|
+
if (p.length < 12) return 'Use at least 12 characters.';
|
|
28
|
+
if (p.length > 256) return 'Use at most 256 characters.';
|
|
29
|
+
if (!/[A-Z]/.test(p)) return 'Add an uppercase letter.';
|
|
30
|
+
if (!/[a-z]/.test(p)) return 'Add a lowercase letter.';
|
|
31
|
+
if (!/[0-9]/.test(p)) return 'Add a number.';
|
|
32
|
+
if (!SPECIAL.test(p)) return 'Add a special character, for example - or !';
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
export const emailOk = (e) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(e ?? '').trim());
|
|
36
|
+
|
|
37
|
+
/** 3 groups of 6 from an unambiguous alphabet, joined with '-', plus one guaranteed digit and capital. */
|
|
38
|
+
export function generatePassword() {
|
|
39
|
+
const A = 'abcdefghjkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
|
40
|
+
for (;;) {
|
|
41
|
+
const bytes = randomBytes(18);
|
|
42
|
+
const groups = [0, 1, 2].map(g => Array.from(bytes.subarray(g * 6, g * 6 + 6), b => A[b % A.length]).join(''));
|
|
43
|
+
const pw = `${groups.join('-')}-${randomInt(2, 10)}${'ABCDEFGHJKLMNPQRSTUVWXYZ'[randomInt(0, 24)]}`;
|
|
44
|
+
if (!passwordProblem(pw)) return pw;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const apiError = (e, fallback) => (e?.message && !/^\s*$/.test(e.message) ? e.message : fallback);
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* -> finisher { kind, profileUrl, createOwner, signIn, storeCredential, loginLink }
|
|
52
|
+
* Dependencies are the CLI's own modules; tests run them for real against a fake instance.
|
|
53
|
+
*/
|
|
54
|
+
export async function createFinisher({ profileUrl = LOCAL_PROFILE_URL, fetchImpl = globalThis.fetch } = {}) {
|
|
55
|
+
const { loadConfig } = await import('../../core/config.js');
|
|
56
|
+
const { createAuth } = await import('../../core/auth.js');
|
|
57
|
+
const { createClient } = await import('../../core/client.js');
|
|
58
|
+
const { createApi } = await import('../api.js');
|
|
59
|
+
|
|
60
|
+
// Nothing is written here: the profile is pointed at the new instance only at sign-in, so leaving
|
|
61
|
+
// Getting started early never leaves a half-configured profile behind.
|
|
62
|
+
const config = loadConfig();
|
|
63
|
+
const auth = createAuth(config);
|
|
64
|
+
const client = createClient(config, auth);
|
|
65
|
+
const authUrl = () => config.getServiceUrl('auth');
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
kind: 'real',
|
|
69
|
+
profileUrl,
|
|
70
|
+
authUrl,
|
|
71
|
+
|
|
72
|
+
/** -> { ok: true } | { ok: false, existed?: true, error } */
|
|
73
|
+
async createOwner({ email, password, name }) {
|
|
74
|
+
const display = name || String(email).split('@')[0];
|
|
75
|
+
let r;
|
|
76
|
+
try {
|
|
77
|
+
r = await fetchImpl(`${authUrl()}/api/setup/admin`, {
|
|
78
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
79
|
+
body: JSON.stringify({ email: String(email).trim(), password, name: display, display_name: display })
|
|
80
|
+
});
|
|
81
|
+
} catch (e) { return { ok: false, error: `the auth service did not answer: ${e?.cause?.code || e?.message}` }; }
|
|
82
|
+
let body = null;
|
|
83
|
+
try { body = await r.json(); } catch { /* not json */ }
|
|
84
|
+
if (r.ok && body?.success) return { ok: true };
|
|
85
|
+
if (r.status === 400 && body?.error === 'admin_exists') return { ok: false, existed: true, error: body.message };
|
|
86
|
+
return { ok: false, error: body?.message || `HTTP ${r.status}` };
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
/** -> { ok: true, org } | { ok: false, error }. Stores the login token in the active profile. */
|
|
90
|
+
async signIn({ email, password }) {
|
|
91
|
+
try {
|
|
92
|
+
config.setProfileConnection({ url: profileUrl });
|
|
93
|
+
const data = await auth.login(authUrl(), String(email).trim(), password);
|
|
94
|
+
if (data?.mfa_required) return { ok: false, error: 'This account uses MFA. Finish signing in with: quox login' };
|
|
95
|
+
return { ok: true, org: data?.org?.name || null };
|
|
96
|
+
} catch (e) { return { ok: false, error: apiError(e, 'sign-in failed') }; }
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
/** held: a held secret from credential.js (or null). -> connectFlow result | { ok: false, skipped: true } */
|
|
100
|
+
async storeCredential(held) {
|
|
101
|
+
if (!held || typeof held.reveal !== 'function') return { ok: false, skipped: true };
|
|
102
|
+
try {
|
|
103
|
+
const api = createApi({ client, config, auth });
|
|
104
|
+
return await connectFlow(api, held.provider, held.reveal());
|
|
105
|
+
} finally {
|
|
106
|
+
held.drop();
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
|
|
110
|
+
/** -> { ok: true, url, expiresAt, ttlSeconds } | { ok: false, error } */
|
|
111
|
+
async loginLink(dashboardUrl) {
|
|
112
|
+
try {
|
|
113
|
+
const r = await client.post('/api/auth/login-link', { service: 'auth', body: {}, retries: 0 });
|
|
114
|
+
if (!r?.path || !/^\/api\/auth\/login-link\/consume\?t=/.test(r.path)) return { ok: false, error: 'unexpected answer from the auth service' };
|
|
115
|
+
return { ok: true, url: new URL(r.path, dashboardUrl || profileUrl).href, expiresAt: r.expires_at, ttlSeconds: r.ttl_seconds };
|
|
116
|
+
} catch (e) { return { ok: false, error: apiError(e, 'could not create the link') }; }
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Entry for Getting started (main.js calls this when no server is configured, or for --demo-onboarding).
|
|
3
|
+
* Runs the Ink view, then performs what it asked for: exit, or hand off to the existing `quox login` flow for
|
|
4
|
+
* `/connect server <url>`.
|
|
5
|
+
*/
|
|
6
|
+
import React from 'react';
|
|
7
|
+
import { render } from 'ink';
|
|
8
|
+
import { spawnSync } from 'node:child_process';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
import { GettingStarted } from './view.js';
|
|
11
|
+
import { inkStdout } from '../term.js';
|
|
12
|
+
import { createCredentialBroker, createRequestCredential } from './credential.js';
|
|
13
|
+
import { createDryRunRunner } from './runner.js';
|
|
14
|
+
import { createInstallRunner, defaultInstallDir, probeAuthHealth } from './installRunner.js';
|
|
15
|
+
import { createFinisher } from './finish.js';
|
|
16
|
+
import { journeyPath as defaultJourneyPath, clearJourney, loadJourney } from './journey.js';
|
|
17
|
+
import * as T from './copy.js';
|
|
18
|
+
|
|
19
|
+
const QUOX_BIN = fileURLToPath(new URL('../../../bin/quox.js', import.meta.url));
|
|
20
|
+
|
|
21
|
+
/** The real install: get.quox.ai -> scripts/setup.sh, health = the new instance's auth service on loopback. */
|
|
22
|
+
export async function realInstall({ env = process.env } = {}) {
|
|
23
|
+
// The CLI's own service resolution (honours the per-service overrides); with no server configured
|
|
24
|
+
// yet it derives auth on loopback 127.0.0.1:3101, where the new instance's auth service listens.
|
|
25
|
+
const { loadConfig } = await import('../../core/config.js');
|
|
26
|
+
const authUrl = loadConfig().getServiceUrl('auth');
|
|
27
|
+
const saved = loadJourney();
|
|
28
|
+
return {
|
|
29
|
+
runner: createInstallRunner({ env, installDir: saved?.installDir || defaultInstallDir(), probeHealth: () => probeAuthHealth(authUrl) }),
|
|
30
|
+
finisher: await createFinisher()
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** -> { action: 'exit' } | { action: 'connected', replay? } | { action: 'connect-failed' } */
|
|
35
|
+
export async function runGettingStarted({ version, cwd, welcome = false, earlyInput = null, demo = false, stdout = process.stdout } = {}) {
|
|
36
|
+
const broker = createCredentialBroker();
|
|
37
|
+
const requestCredential = createRequestCredential(broker);
|
|
38
|
+
let result = { action: 'exit' };
|
|
39
|
+
const real = demo ? { runner: createDryRunRunner(), finisher: null } : await realInstall();
|
|
40
|
+
const props = {
|
|
41
|
+
version, cwd, welcome, earlyInput, broker, requestCredential,
|
|
42
|
+
dryRun: demo,
|
|
43
|
+
runner: real.runner,
|
|
44
|
+
finisher: real.finisher, // owner account, vault, one-time link (C2c); none in the dry run
|
|
45
|
+
journeyPath: demo ? null : defaultJourneyPath(), // the demo never leaves state behind
|
|
46
|
+
onExit: (r) => { result = r || result; app.unmount(); }
|
|
47
|
+
};
|
|
48
|
+
const app = render(React.createElement(GettingStarted, props), { exitOnCtrlC: false, stdout: inkStdout(stdout) });
|
|
49
|
+
await app.waitUntilExit();
|
|
50
|
+
broker.cancel();
|
|
51
|
+
if (result.action === 'connect') return connectServer(result.url, { demo });
|
|
52
|
+
return result;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** /connect server <url>: point the active profile at it and run the existing login. Restores the URL on failure. */
|
|
56
|
+
export async function connectServer(url, { demo = false, spawn = spawnSync, out = (s) => process.stdout.write(s) } = {}) {
|
|
57
|
+
if (demo) { out(`${T.connectHandoff(url)}\n(demo: not connecting)\n`); return { action: 'exit' }; }
|
|
58
|
+
const { loadConfig } = await import('../../core/config.js');
|
|
59
|
+
const config = loadConfig();
|
|
60
|
+
const prev = config.getActiveProfile()?.url || null;
|
|
61
|
+
config.setProfileConnection({ url });
|
|
62
|
+
const r = spawn(process.execPath, [QUOX_BIN, 'login'], { stdio: 'inherit' });
|
|
63
|
+
if (r.status === 0) { clearJourney(); out(`${T.CONNECT_DONE}\n`); return { action: 'connected' }; }
|
|
64
|
+
if (prev) loadConfig().setProfileConnection({ url: prev });
|
|
65
|
+
out(`${T.connectFailed(url)}\n`);
|
|
66
|
+
return { action: 'connect-failed' };
|
|
67
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* C2b: the real install runner (the runner interface in ./runner.js).
|
|
3
|
+
*
|
|
4
|
+
* Wraps the existing installer, never a second one: get.quox.ai serves install.sh (quox-website
|
|
5
|
+
* public/install.sh), which clones quox, quoxflow and quoxmcp into $QUOX_DIR and runs
|
|
6
|
+
* scripts/setup.sh --yes (non-interactive). We download it to a private temp file, show its
|
|
7
|
+
* SHA256, run it with `sh`, and map its own step markers onto the five honest stages:
|
|
8
|
+
*
|
|
9
|
+
* fetch start .. "[setup] Step 2/7" (install.sh clones; setup.sh step 1 preflight)
|
|
10
|
+
* configure Step 2/7 .. Step 6/7 (.env secrets, SSL cert, deps, stale cleanup)
|
|
11
|
+
* build Step 6/7 .. "Starting services" (docker compose build)
|
|
12
|
+
* start "Starting services" .. Step 7/7 (docker compose up -d)
|
|
13
|
+
* health Step 7/7 .. "full platform is ready" + our own probe of the auth service
|
|
14
|
+
*
|
|
15
|
+
* Re-running (setup.sh read, 2026-10-12): clone() pulls instead of re-cloning; an existing complete
|
|
16
|
+
* .env and ssl/quox.crt are kept (so the Postgres password still matches its volume); the image
|
|
17
|
+
* build is cached. Step 5 however REMOVES running quox-* containers (compose down, volumes kept)
|
|
18
|
+
* before rebuilding. So: interrupted before `start` completed -> run the whole installer again;
|
|
19
|
+
* `start` completed -> never re-run it, only check health.
|
|
20
|
+
*
|
|
21
|
+
* Never a fake percentage: elapsed time per stage, and the real log lines (`v` in the view).
|
|
22
|
+
*/
|
|
23
|
+
import { spawn as defaultSpawn } from 'node:child_process';
|
|
24
|
+
import { createHash } from 'node:crypto';
|
|
25
|
+
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
|
|
26
|
+
import { homedir, tmpdir } from 'node:os';
|
|
27
|
+
import { join } from 'node:path';
|
|
28
|
+
import { STAGE_IDS, stageList } from './runner.js';
|
|
29
|
+
|
|
30
|
+
export const INSTALLER_URL = 'https://get.quox.ai';
|
|
31
|
+
export const defaultInstallDir = (home = homedir()) => join(home, 'quox-install');
|
|
32
|
+
|
|
33
|
+
const ANSI = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;
|
|
34
|
+
const RED = /^\x1b\[(0;)?31m/;
|
|
35
|
+
const MARKS = [
|
|
36
|
+
[/^\[setup\] Step 2\/7\b/, 'configure'],
|
|
37
|
+
[/^\[setup\] Step 6\/7\b/, 'build'],
|
|
38
|
+
[/Starting services\.\.\./, 'start'],
|
|
39
|
+
[/^\[setup\] Step 7\/7\b/, 'health']
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Line-by-line stage machine over the installer's output.
|
|
44
|
+
* push(rawLine) -> { line: cleaned text | null, moves: [[stageId, 'ok'|'running']] }
|
|
45
|
+
*/
|
|
46
|
+
export function createStageParser() {
|
|
47
|
+
const state = { current: 'fetch', ready: false, degraded: false, dashboardUrl: null, lastError: null };
|
|
48
|
+
return {
|
|
49
|
+
state,
|
|
50
|
+
push(raw, { stderr = false } = {}) {
|
|
51
|
+
const text = String(raw ?? '').replace(ANSI, '').replace(/\s+$/, '');
|
|
52
|
+
const moves = [];
|
|
53
|
+
if (!text.trim()) return { line: null, moves };
|
|
54
|
+
const line = text.replace(/^\s+/, (m) => (m.length > 2 ? ' ' : m));
|
|
55
|
+
for (const [re, next] of MARKS) {
|
|
56
|
+
if (!re.test(line.trim())) continue;
|
|
57
|
+
const from = STAGE_IDS.indexOf(state.current); const to = STAGE_IDS.indexOf(next);
|
|
58
|
+
if (to <= from) break;
|
|
59
|
+
for (let i = from; i < to; i++) { moves.push([STAGE_IDS[i], 'ok']); if (i + 1 < to) moves.push([STAGE_IDS[i + 1], 'running']); }
|
|
60
|
+
moves.push([next, 'running']);
|
|
61
|
+
state.current = next;
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
if (/QuoxCORE full platform is ready/.test(line)) state.ready = true;
|
|
65
|
+
if (/core shell is up, but platform features are degraded/.test(line)) state.degraded = true;
|
|
66
|
+
const open = /^\s*Open:\s+(https?:\/\/\S+)/.exec(line);
|
|
67
|
+
if (open) state.dashboardUrl = open[1];
|
|
68
|
+
if (stderr || RED.test(String(raw))) state.lastError = line.trim();
|
|
69
|
+
return { line: line.trim(), moves };
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** GET <authUrl>/health once. -> { ok, error? } */
|
|
75
|
+
export async function probeAuthHealth(authUrl, { fetchImpl = globalThis.fetch, timeout = 3000 } = {}) {
|
|
76
|
+
try {
|
|
77
|
+
const r = await fetchImpl(`${authUrl.replace(/\/+$/, '')}/health`, { signal: AbortSignal.timeout(timeout) });
|
|
78
|
+
return r.ok ? { ok: true } : { ok: false, error: `auth /health answered HTTP ${r.status}` };
|
|
79
|
+
} catch (e) { return { ok: false, error: e?.cause?.code ? `${e.message} (${e.cause.code})` : (e?.message || 'no answer') }; }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const sleep = (ms, signal) => new Promise((res) => {
|
|
83
|
+
const t = setTimeout(res, ms);
|
|
84
|
+
signal?.addEventListener?.('abort', () => { clearTimeout(t); res(); }, { once: true });
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
export function createInstallRunner({
|
|
88
|
+
env = process.env,
|
|
89
|
+
url = env.QUOX_INSTALLER_URL || INSTALLER_URL,
|
|
90
|
+
installDir = defaultInstallDir(),
|
|
91
|
+
fetchImpl = globalThis.fetch,
|
|
92
|
+
spawnImpl = defaultSpawn,
|
|
93
|
+
tmpRoot = tmpdir(),
|
|
94
|
+
probeHealth = async () => ({ ok: true }),
|
|
95
|
+
healthTries = 20,
|
|
96
|
+
healthIntervalMs = 3000,
|
|
97
|
+
resumeFrom = null,
|
|
98
|
+
resumeDashboardUrl = null,
|
|
99
|
+
onCheckpoint = () => {},
|
|
100
|
+
killGraceMs = 5000,
|
|
101
|
+
now = () => Date.now()
|
|
102
|
+
} = {}) {
|
|
103
|
+
async function checkHealth(emit, signal) {
|
|
104
|
+
let last = null;
|
|
105
|
+
for (let i = 0; i < Math.max(1, healthTries); i++) {
|
|
106
|
+
if (signal?.aborted) return { ok: false, interrupted: true };
|
|
107
|
+
last = await probeHealth();
|
|
108
|
+
if (last?.ok) return { ok: true };
|
|
109
|
+
emit({ type: 'log', line: `waiting for the auth service: ${last?.error || 'not ready'}` });
|
|
110
|
+
if (i + 1 < healthTries) await sleep(healthIntervalMs, signal);
|
|
111
|
+
}
|
|
112
|
+
return { ok: false, error: last?.error || 'the auth service did not answer' };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
kind: 'real',
|
|
117
|
+
url,
|
|
118
|
+
installDir,
|
|
119
|
+
stages: stageList(),
|
|
120
|
+
/** Per-run overrides (the view passes the journey's checkpoint): resumeFrom, resumeDashboardUrl, onCheckpoint. */
|
|
121
|
+
async run({ onEvent = () => {}, signal, resumeFrom: rf = resumeFrom, resumeDashboardUrl: rd = resumeDashboardUrl, onCheckpoint: oc = onCheckpoint } = {}) {
|
|
122
|
+
const resumeAfterStart = rf === 'start' || rf === 'health';
|
|
123
|
+
const started = {};
|
|
124
|
+
const stage = (id, status) => {
|
|
125
|
+
const at = now();
|
|
126
|
+
if (status === 'running') started[id] = at;
|
|
127
|
+
onEvent({ type: 'stage', id, status, at, ...(status !== 'running' ? { ms: started[id] != null ? at - started[id] : null } : {}) });
|
|
128
|
+
};
|
|
129
|
+
const emit = (e) => onEvent(e);
|
|
130
|
+
|
|
131
|
+
if (resumeAfterStart) {
|
|
132
|
+
for (const id of ['fetch', 'configure', 'build', 'start']) onEvent({ type: 'stage', id, status: 'ok', at: now(), ms: null, earlier: true });
|
|
133
|
+
emit({ type: 'log', line: 'the services were already started; checking their health' });
|
|
134
|
+
stage('health', 'running');
|
|
135
|
+
const h = await checkHealth(emit, signal);
|
|
136
|
+
if (h.interrupted) return { ok: false, interrupted: true };
|
|
137
|
+
if (!h.ok) { stage('health', 'fail'); return { ok: false, failedStage: 'health', error: h.error, dashboardUrl: rd }; }
|
|
138
|
+
stage('health', 'ok');
|
|
139
|
+
oc('health', { dashboardUrl: rd });
|
|
140
|
+
return { ok: true, dashboardUrl: rd, resumed: true };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ---- fetch: download the installer, show what will run -------------------------------------
|
|
144
|
+
stage('fetch', 'running');
|
|
145
|
+
let body;
|
|
146
|
+
try {
|
|
147
|
+
const r = await fetchImpl(url, { signal });
|
|
148
|
+
if (!r.ok) { stage('fetch', 'fail'); return { ok: false, failedStage: 'fetch', error: `${url} answered HTTP ${r.status}` }; }
|
|
149
|
+
body = await r.text();
|
|
150
|
+
} catch (e) {
|
|
151
|
+
if (signal?.aborted) return { ok: false, interrupted: true };
|
|
152
|
+
stage('fetch', 'fail');
|
|
153
|
+
return { ok: false, failedStage: 'fetch', error: `could not download ${url}: ${e?.message || e}` };
|
|
154
|
+
}
|
|
155
|
+
if (!/^#!\s*\/bin\/(ba)?sh\b/.test(body)) { stage('fetch', 'fail'); return { ok: false, failedStage: 'fetch', error: `${url} did not return a shell script, so nothing was run` }; }
|
|
156
|
+
const sha256 = createHash('sha256').update(body).digest('hex');
|
|
157
|
+
emit({ type: 'script', url, sha256, bytes: Buffer.byteLength(body) });
|
|
158
|
+
emit({ type: 'log', line: `installer ${url} sha256 ${sha256}` });
|
|
159
|
+
|
|
160
|
+
const dir = mkdtempSync(join(tmpRoot, 'quox-installer-'));
|
|
161
|
+
const file = join(dir, 'install.sh');
|
|
162
|
+
writeFileSync(file, body, { mode: 0o600 });
|
|
163
|
+
|
|
164
|
+
const parser = createStageParser();
|
|
165
|
+
try {
|
|
166
|
+
const code = await new Promise((resolve) => {
|
|
167
|
+
const child = spawnImpl('sh', [file], {
|
|
168
|
+
cwd: homedir(),
|
|
169
|
+
env: { ...env, QUOX_DIR: installDir },
|
|
170
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
171
|
+
detached: true // own process group: abort stops docker compose too
|
|
172
|
+
});
|
|
173
|
+
let killTimer = null;
|
|
174
|
+
const onAbort = () => {
|
|
175
|
+
try { process.kill(-child.pid, 'SIGTERM'); } catch { /* already gone */ }
|
|
176
|
+
killTimer = setTimeout(() => { try { process.kill(-child.pid, 'SIGKILL'); } catch { /* gone */ } }, killGraceMs);
|
|
177
|
+
};
|
|
178
|
+
if (signal) { if (signal.aborted) onAbort(); else signal.addEventListener('abort', onAbort, { once: true }); }
|
|
179
|
+
const feed = (stderr) => {
|
|
180
|
+
let rest = '';
|
|
181
|
+
return (chunk) => {
|
|
182
|
+
const parts = (rest + chunk.toString('utf8')).split(/\r\n|\r|\n/);
|
|
183
|
+
rest = parts.pop();
|
|
184
|
+
for (const raw of parts) take(raw, stderr);
|
|
185
|
+
};
|
|
186
|
+
};
|
|
187
|
+
const take = (raw, stderr) => {
|
|
188
|
+
const { line, moves } = parser.push(raw, { stderr });
|
|
189
|
+
for (const [id, status] of moves) {
|
|
190
|
+
if (status === 'ok') { stage(id, 'ok'); if (id !== 'health') oc(id, {}); }
|
|
191
|
+
else stage(id, 'running');
|
|
192
|
+
}
|
|
193
|
+
if (line) emit({ type: 'log', line });
|
|
194
|
+
};
|
|
195
|
+
child.stdout.on('data', feed(false));
|
|
196
|
+
child.stderr.on('data', feed(true));
|
|
197
|
+
child.on('error', (e) => { parser.state.lastError = `could not run sh: ${e.message}`; resolve(127); });
|
|
198
|
+
child.on('close', (c, sig) => { clearTimeout(killTimer); signal?.removeEventListener?.('abort', onAbort); resolve(sig ? 128 : c); });
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
if (signal?.aborted) return { ok: false, interrupted: true };
|
|
202
|
+
const at = parser.state.current;
|
|
203
|
+
if (code !== 0) {
|
|
204
|
+
stage(at, 'fail');
|
|
205
|
+
return { ok: false, failedStage: at, error: parser.state.lastError || `the installer exited with code ${code}`, dashboardUrl: parser.state.dashboardUrl };
|
|
206
|
+
}
|
|
207
|
+
if (at !== 'health' || !parser.state.ready) {
|
|
208
|
+
if (at !== 'health') stage(at, 'fail'); else stage('health', 'fail');
|
|
209
|
+
return {
|
|
210
|
+
ok: false, failedStage: at === 'health' ? 'health' : at, degraded: parser.state.degraded, dashboardUrl: parser.state.dashboardUrl,
|
|
211
|
+
error: parser.state.degraded ? 'the core is up, but some services are not healthy yet' : 'the installer finished without saying the platform is ready'
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
const h = await checkHealth(emit, signal);
|
|
215
|
+
if (h.interrupted) return { ok: false, interrupted: true };
|
|
216
|
+
if (!h.ok) { stage('health', 'fail'); return { ok: false, failedStage: 'health', error: h.error, dashboardUrl: parser.state.dashboardUrl }; }
|
|
217
|
+
stage('health', 'ok');
|
|
218
|
+
oc('health', { dashboardUrl: parser.state.dashboardUrl });
|
|
219
|
+
return { ok: true, dashboardUrl: parser.state.dashboardUrl, sha256 };
|
|
220
|
+
} finally {
|
|
221
|
+
try { rmSync(dir, { recursive: true, force: true }); } catch { /* temp cleanup only */ }
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local intent matcher for Getting started. No model, no network: a keyword table, so the answer is instant
|
|
3
|
+
* and predictable. Anything it does not recognise is "offscript" and gets an honest deferral.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const INTENTS = ['servers', 'automate', 'code', 'email', 'explore'];
|
|
7
|
+
|
|
8
|
+
// Order matters: the first rule whose pattern matches wins ("connect" first, so "I already have a server"
|
|
9
|
+
// is never read as "watch my servers").
|
|
10
|
+
const RULES = [
|
|
11
|
+
['connect', /\b(already (have|got|run)|have (a|my|an?) (own )?(server|quox|instance)|existing (server|quox|instance)|my (own )?quox(core)?|connect (to )?(a |my )?(server|quox))\b/i],
|
|
12
|
+
['servers', /\b(servers?|hosts?|fleet|monitor(ing)?|watch(ing)?|uptime|infra(structure)?|machines?|homelab|proxmox|vms?|nodes?|cluster|disk space|logs?)\b/i],
|
|
13
|
+
['email', /\b(e-?mails?|mail|inbox|gmail|outlook|newsletters?|replies)\b/i],
|
|
14
|
+
['code', /\b(code|coding|coder|repos?|repository|github|gitlab|pull requests?|prs?|bugs?|refactor|programm(ing|er)|develop(er|ment)?|tests?)\b/i],
|
|
15
|
+
['automate', /\b(automat(e|ion|ing)|workflows?|schedul(e|ed|ing)|cron|routines?|repetitive|every (day|morning|week|hour)|n8n|zapier|tasks?|jobs?)\b/i],
|
|
16
|
+
['explore', /\b(look(ing)? around|explore|just looking|curious|show me|demo|tour|not sure|no idea|anything|surprise me|what can (you|it|quox) do|browse)\b/i]
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
/** -> { intent: one of INTENTS | 'connect' | 'offscript' | 'empty', text } */
|
|
20
|
+
export function matchIntent(input) {
|
|
21
|
+
const text = String(input ?? '').trim();
|
|
22
|
+
if (!text) return { intent: 'empty', text };
|
|
23
|
+
const n = /^[1-5]$/.exec(text);
|
|
24
|
+
if (n) return { intent: INTENTS[Number(n[0]) - 1], text };
|
|
25
|
+
for (const [intent, re] of RULES) if (re.test(text)) return { intent, text };
|
|
26
|
+
return { intent: 'offscript', text };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Y/n answer: Enter alone means yes. -> true | false | null (not an answer) */
|
|
30
|
+
export function yesNo(input) {
|
|
31
|
+
const t = String(input ?? '').trim().toLowerCase();
|
|
32
|
+
if (t === '' || /^(y|yes|yeah|yep|sure|ok|okay|go|do it|set it up|please)\b/.test(t)) return true;
|
|
33
|
+
if (/^(n|no|nope|not now|later|skip|cancel)\b/.test(t)) return false;
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** `/connect server <url>` -> { ok, url } | { ok: false, reason: 'usage' | 'url', value } | null (not /connect) */
|
|
38
|
+
export function parseConnect(input) {
|
|
39
|
+
const t = String(input ?? '').trim();
|
|
40
|
+
if (!/^\/connect\b/i.test(t)) return null;
|
|
41
|
+
const m = /^\/connect\s+server(?:\s+(\S+))?\s*$/i.exec(t);
|
|
42
|
+
if (!m) return { ok: false, reason: 'usage' };
|
|
43
|
+
if (!m[1]) return { ok: false, reason: 'usage' };
|
|
44
|
+
let raw = m[1];
|
|
45
|
+
if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(raw)) raw = `https://${raw}`;
|
|
46
|
+
try {
|
|
47
|
+
const u = new URL(raw);
|
|
48
|
+
if (!['http:', 'https:'].includes(u.protocol) || !u.hostname) return { ok: false, reason: 'url', value: m[1] };
|
|
49
|
+
return { ok: true, url: u.origin + (u.pathname === '/' ? '' : u.pathname.replace(/\/+$/, '')) };
|
|
50
|
+
} catch {
|
|
51
|
+
return { ok: false, reason: 'url', value: m[1] };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Journey state for Getting started: ~/.config/quox/onboarding.json, so an interrupted journey resumes.
|
|
3
|
+
* ALLOWLIST ONLY: the file holds exactly the fields named here (never a key, token or password), whatever the
|
|
4
|
+
* caller passes in.
|
|
5
|
+
*/
|
|
6
|
+
import { readFileSync, writeFileSync, mkdirSync, rmSync, renameSync } from 'node:fs';
|
|
7
|
+
import { homedir } from 'node:os';
|
|
8
|
+
import { join, dirname } from 'node:path';
|
|
9
|
+
|
|
10
|
+
export const JOURNEY_VERSION = 1;
|
|
11
|
+
export const STEPS = ['ask', 'offer', 'declined', 'preflight', 'credential', 'install', 'owner', 'finishing', 'finale'];
|
|
12
|
+
// installStage = the last install stage that COMPLETED (installRunner.js decides what re-running means).
|
|
13
|
+
const FIELDS = ['step', 'intent', 'request', 'provider', 'installStage', 'installDir', 'dashboardUrl', 'ownerEmail', 'ollamaUrl', 'startedAt', 'updatedAt'];
|
|
14
|
+
const INSTALL_STAGES = ['fetch', 'configure', 'build', 'start', 'health'];
|
|
15
|
+
const MAX = { request: 500, installDir: 300, dashboardUrl: 300, ownerEmail: 254, ollamaUrl: 200 };
|
|
16
|
+
|
|
17
|
+
export const journeyPath = () => join(homedir(), '.config', 'quox', 'onboarding.json');
|
|
18
|
+
|
|
19
|
+
export function sanitize(state) {
|
|
20
|
+
const out = { version: JOURNEY_VERSION };
|
|
21
|
+
for (const k of FIELDS) {
|
|
22
|
+
const v = state?.[k];
|
|
23
|
+
if (v === undefined || v === null) continue;
|
|
24
|
+
if (typeof v !== 'string') continue;
|
|
25
|
+
out[k] = v.slice(0, MAX[k] || 64);
|
|
26
|
+
}
|
|
27
|
+
if (out.installStage && !INSTALL_STAGES.includes(out.installStage)) delete out.installStage;
|
|
28
|
+
if (out.dashboardUrl && !/^https?:\/\/[^\s]+$/.test(out.dashboardUrl)) delete out.dashboardUrl;
|
|
29
|
+
if (out.installDir && !out.installDir.startsWith('/')) delete out.installDir;
|
|
30
|
+
if (out.step && !STEPS.includes(out.step)) delete out.step;
|
|
31
|
+
if (out.provider && !['chatgpt', 'openai', 'anthropic', 'ollama', 'skip'].includes(out.provider)) delete out.provider;
|
|
32
|
+
if (out.ollamaUrl !== undefined && (typeof out.ollamaUrl !== 'string' || !/^https?:\/\//.test(out.ollamaUrl))) delete out.ollamaUrl; // B3: a URL, never a secret
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function loadJourney(path = journeyPath()) {
|
|
37
|
+
try {
|
|
38
|
+
const raw = JSON.parse(readFileSync(path, 'utf8'));
|
|
39
|
+
if (!raw || raw.version !== JOURNEY_VERSION) return null;
|
|
40
|
+
const s = sanitize(raw);
|
|
41
|
+
return s.step ? s : null;
|
|
42
|
+
} catch { return null; }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function saveJourney(state, path = journeyPath(), now = () => new Date().toISOString()) {
|
|
46
|
+
if (!path) return false;
|
|
47
|
+
try {
|
|
48
|
+
const prev = loadJourney(path);
|
|
49
|
+
const t = now();
|
|
50
|
+
const next = sanitize({ ...state, startedAt: prev?.startedAt || state.startedAt || t, updatedAt: t });
|
|
51
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
52
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
53
|
+
writeFileSync(tmp, JSON.stringify(next, null, 2) + '\n', { mode: 0o600 });
|
|
54
|
+
renameSync(tmp, path);
|
|
55
|
+
return true;
|
|
56
|
+
} catch { return false; }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function clearJourney(path = journeyPath()) {
|
|
60
|
+
if (!path) return;
|
|
61
|
+
try { rmSync(path, { force: true }); } catch { /* nothing to clear */ }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Where a resumed journey starts. The AI key only ever lived in memory, so anything past the credential
|
|
66
|
+
* step goes back through preflight (cheap, and the machine may have changed) and asks for the key again.
|
|
67
|
+
*/
|
|
68
|
+
export function resumeStep(saved) {
|
|
69
|
+
if (!saved?.step) return null;
|
|
70
|
+
if (saved.step === 'ask' || saved.step === 'finale') return null;
|
|
71
|
+
if (saved.step === 'offer' || saved.step === 'declined') return 'offer';
|
|
72
|
+
return 'preflight';
|
|
73
|
+
}
|