quoxcli 0.2.0 → 0.4.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 +1 -1
- package/README.md +2 -0
- package/bin/quox.js +99 -126
- package/lib/api/endpoints/actions.js +15 -0
- package/lib/api/endpoints/admin.js +46 -0
- package/lib/api/endpoints/agent-schedules.js +26 -0
- package/lib/api/endpoints/agents.js +7 -0
- package/lib/api/endpoints/assistants.js +12 -0
- package/lib/api/endpoints/away.js +15 -0
- package/lib/api/endpoints/blog.js +16 -0
- package/lib/api/endpoints/chat.js +14 -0
- package/lib/api/endpoints/contactsq.js +6 -0
- package/lib/api/endpoints/conversations.js +14 -0
- package/lib/api/endpoints/devServerRegistry.js +25 -0
- package/lib/api/endpoints/engagement.js +26 -0
- package/lib/api/endpoints/evidence.js +20 -0
- package/lib/api/endpoints/executions.js +13 -0
- package/lib/api/endpoints/fetchAllowlist.js +27 -0
- package/lib/api/endpoints/files.js +14 -0
- package/lib/api/endpoints/hat.js +44 -0
- package/lib/api/endpoints/inbox.js +20 -0
- package/lib/api/endpoints/index.js +26 -0
- package/lib/api/endpoints/loops.js +26 -0
- package/lib/api/endpoints/memory.js +19 -0
- package/lib/api/endpoints/mind.js +13 -0
- package/lib/api/endpoints/mon.js +179 -0
- package/lib/api/endpoints/objectives.js +12 -0
- package/lib/api/endpoints/plugins.js +18 -0
- package/lib/api/endpoints/presence.js +33 -0
- package/lib/api/endpoints/quoxplan.js +7 -0
- package/lib/api/endpoints/quoxseo.js +12 -0
- package/lib/api/endpoints/router.js +17 -0
- package/lib/api/endpoints/safety.js +7 -0
- package/lib/api/endpoints/server.js +21 -0
- package/lib/api/endpoints/setup.js +6 -0
- package/lib/api/endpoints/skills.js +20 -0
- package/lib/api/endpoints/stepup.js +20 -0
- package/lib/api/endpoints/teams.js +2 -2
- package/lib/api/endpoints/triggers.js +26 -0
- package/lib/api/endpoints/v1.js +120 -0
- package/lib/api/endpoints/ward.js +7 -0
- package/lib/api/endpoints/workflows.js +7 -0
- package/lib/api/endpoints/x.js +8 -0
- package/lib/commands/actions.js +131 -0
- package/lib/commands/admin.js +16 -0
- package/lib/commands/agent.js +170 -27
- package/lib/commands/agentAttach.js +140 -13
- package/lib/commands/approvals.js +78 -1
- package/lib/commands/aside.js +24 -0
- package/lib/commands/ask.js +146 -0
- package/lib/commands/assistant.js +59 -4
- package/lib/commands/auth.js +8 -2
- package/lib/commands/backup.js +8 -4
- package/lib/commands/blog.js +110 -0
- package/lib/commands/brain.js +105 -14
- package/lib/commands/build.js +19 -5
- package/lib/commands/chat.js +180 -26
- package/lib/commands/chatCompanion.js +27 -0
- package/lib/commands/chatState.js +45 -0
- package/lib/commands/claude.js +35 -38
- package/lib/commands/contacts.js +134 -4
- package/lib/commands/contactsSendAt.js +21 -0
- package/lib/commands/conversation.js +35 -4
- package/lib/commands/dashboard.js +43 -0
- package/lib/commands/department.js +28 -7
- package/lib/commands/devResident.js +15 -3
- package/lib/commands/devSupervisor.js +13 -1
- package/lib/commands/discord.js +75 -39
- package/lib/commands/done.js +98 -28
- package/lib/commands/engagement.js +107 -6
- package/lib/commands/evidence.js +154 -0
- package/lib/commands/ext.js +23 -0
- package/lib/commands/fetchAllowlist.js +71 -0
- package/lib/commands/file.js +38 -1
- package/lib/commands/fleet.js +336 -4
- package/lib/commands/governance.js +12 -3
- package/lib/commands/gripe.js +44 -13
- package/lib/commands/hat.js +254 -9
- package/lib/commands/helpers.js +18 -1
- package/lib/commands/hyperion.js +58 -0
- package/lib/commands/image.js +68 -0
- package/lib/commands/inbox.js +74 -23
- package/lib/commands/kuma.js +1050 -0
- package/lib/commands/kumaSla.js +174 -0
- package/lib/commands/licence.js +52 -155
- package/lib/commands/loop.js +291 -0
- package/lib/commands/lucid.js +10 -4
- package/lib/commands/memory.js +85 -8
- package/lib/commands/mind.js +71 -0
- package/lib/commands/mon.js +69 -2
- package/lib/commands/monitoring.js +1 -1
- package/lib/commands/motion.js +2 -2
- package/lib/commands/nucleus.js +48 -17
- package/lib/commands/objective.js +59 -1
- package/lib/commands/offload.js +89 -0
- package/lib/commands/onboard.js +2 -1
- package/lib/commands/org.js +30 -3
- package/lib/commands/peek.js +28 -0
- package/lib/commands/plan.js +38 -0
- package/lib/commands/plugin.js +110 -0
- package/lib/commands/presence.js +89 -0
- package/lib/commands/provider.js +217 -0
- package/lib/commands/qlarity.js +19 -3
- package/lib/commands/quoxchat.js +13 -4
- package/lib/commands/quoxseo.js +69 -0
- package/lib/commands/reach.js +2 -1
- package/lib/commands/research.js +79 -0
- package/lib/commands/router.js +77 -0
- package/lib/commands/rows.js +367 -0
- package/lib/commands/run.js +82 -10
- package/lib/commands/scope.js +2 -2
- package/lib/commands/server.js +405 -0
- package/lib/commands/setup.js +33 -7
- package/lib/commands/shortcut.js +41 -0
- package/lib/commands/skills.js +68 -9
- package/lib/commands/slot.js +260 -7
- package/lib/commands/status.js +1 -1
- package/lib/commands/team.js +3 -1
- package/lib/commands/tips.js +23 -0
- package/lib/commands/tool.js +133 -45
- package/lib/commands/trace.js +12 -7
- package/lib/commands/trigger.js +95 -0
- package/lib/commands/update.js +46 -0
- package/lib/commands/vault.js +31 -8
- package/lib/commands/vm.js +237 -0
- package/lib/commands/voice.js +34 -3
- package/lib/commands/volt.js +45 -3
- package/lib/commands/walkthrough.js +32 -0
- package/lib/commands/watch.js +73 -21
- package/lib/commands/welcome.js +14 -0
- package/lib/commands/workflow.js +189 -32
- package/lib/commands/x.js +13 -4
- package/lib/commands/youtube.js +172 -0
- package/lib/core/actingAs.js +2 -2
- package/lib/core/actionItems.js +115 -0
- package/lib/core/activeSummary.js +186 -0
- package/lib/core/agentAlias.js +2 -0
- package/lib/core/agentFamilies.js +7 -0
- package/lib/core/aside.js +54 -0
- package/lib/core/askDecision.js +60 -0
- package/lib/core/auth.js +164 -13
- package/lib/core/callerSource.js +1 -1
- package/lib/core/cliBuild.js +62 -0
- package/lib/core/client.js +152 -35
- package/lib/core/commandRegistrars.js +130 -0
- package/lib/core/config.js +143 -25
- package/lib/core/customHats.js +168 -0
- package/lib/core/doneEngineFetch.js +8 -0
- package/lib/core/errors.js +329 -20
- package/lib/core/findingReconcile.js +90 -0
- package/lib/core/global-flags.js +105 -5
- package/lib/core/governedSession.js +1 -3
- package/lib/core/hyperion.js +102 -0
- package/lib/core/inboxResolution.js +65 -0
- package/lib/core/lazyCommands.js +4 -1
- package/lib/core/liveSessionsReport.js +1 -1
- package/lib/core/loopGlyph.js +97 -0
- package/lib/core/offload.js +533 -0
- package/lib/core/org-override.js +1 -1
- package/lib/core/orgResolve.js +70 -0
- package/lib/core/output.js +11 -11
- package/lib/core/peek.js +126 -0
- package/lib/core/profileUsers.js +51 -0
- package/lib/core/promptCancel.js +18 -0
- package/lib/core/repoPath.js +11 -0
- package/lib/core/residentSeed.js +3 -1
- package/lib/core/rootOptions.js +39 -0
- package/lib/core/router.js +168 -0
- package/lib/core/selfUpdate.js +188 -0
- package/lib/core/sessionHeartbeat.js +12 -8
- package/lib/core/sessionsOverview.js +64 -0
- package/lib/core/shortcut.js +83 -0
- package/lib/core/slotKeeper.js +248 -3
- package/lib/core/slotWake.js +120 -0
- package/lib/core/supervisorEngine.js +71 -2
- package/lib/core/version.js +1 -1
- package/lib/core/vm.js +366 -0
- package/lib/core/vmTui.js +86 -0
- package/lib/core/walkthrough.js +35 -0
- package/lib/dragon/companionView.js +34 -0
- package/lib/dragon/frames.js +118 -0
- package/lib/dragon/jobs.js +123 -0
- package/lib/dragon/renderWorker.js +15 -0
- package/lib/dragon/select.js +72 -0
- package/lib/evidence/bundleExport.js +47 -0
- package/lib/evidence/bundleVerify.js +77 -0
- package/lib/hyperion/deck.js +29 -0
- package/lib/lucid/LucidCard.jsx +68 -0
- package/lib/lucid/LucidIntro.jsx +45 -58
- package/lib/lucid/cards.js +86 -0
- package/lib/lucid/markdown.js +81 -0
- package/lib/lucid/motion.js +56 -0
- package/lib/quoxon/EXTENSIONS.md +23 -0
- package/lib/quoxon/activity.js +14 -2
- package/lib/quoxon/aocl.js +117 -0
- package/lib/quoxon/api.js +690 -25
- package/lib/quoxon/app.js +3586 -250
- package/lib/quoxon/approvalFacts.js +96 -0
- package/lib/quoxon/approvalSentence.js +44 -0
- package/lib/quoxon/approvalsView.js +384 -0
- package/lib/quoxon/asideView.js +40 -0
- package/lib/quoxon/ask.js +121 -0
- package/lib/quoxon/away.js +52 -0
- package/lib/quoxon/bang.js +224 -0
- package/lib/quoxon/bulkRisk.js +17 -0
- package/lib/quoxon/capabilities.js +13 -6
- package/lib/quoxon/cells.js +53 -0
- package/lib/quoxon/commandShort.js +124 -0
- package/lib/quoxon/commands.js +429 -39
- package/lib/quoxon/connect.js +210 -23
- package/lib/quoxon/connectCard.js +19 -2
- package/lib/quoxon/consent.js +86 -0
- package/lib/quoxon/copy.js +4 -3
- package/lib/quoxon/countdownLine.js +20 -0
- package/lib/quoxon/deck.js +30 -0
- package/lib/quoxon/demo.autoplay.js +29 -0
- package/lib/quoxon/demo.fixtures.js +1 -1
- package/lib/quoxon/demo.js +46 -3
- package/lib/quoxon/devObserver.js +20 -0
- package/lib/quoxon/dividerAocl.js +192 -0
- package/lib/quoxon/dividerRule.js +57 -0
- package/lib/quoxon/dragonCompanion.js +255 -0
- package/lib/quoxon/dragonModes.js +151 -0
- package/lib/quoxon/dragonMoments.js +82 -0
- package/lib/quoxon/dragonReads.js +68 -0
- package/lib/quoxon/dragonView.js +41 -0
- package/lib/quoxon/earlyInput.js +40 -0
- package/lib/quoxon/echo.js +15 -0
- package/lib/quoxon/egg.js +2 -2
- package/lib/quoxon/events.js +266 -26
- package/lib/quoxon/extensions.js +276 -0
- package/lib/quoxon/feedback.js +50 -0
- package/lib/quoxon/gallery.js +21 -7
- package/lib/quoxon/handoffOffer.js +78 -0
- package/lib/quoxon/hatSwitch.js +37 -0
- package/lib/quoxon/headless.js +161 -0
- package/lib/quoxon/hostUrls.js +41 -0
- package/lib/quoxon/inboxView.js +79 -0
- package/lib/quoxon/indicator.js +6 -2
- package/lib/quoxon/installCli.js +79 -0
- package/lib/quoxon/intent.js +12 -1
- package/lib/quoxon/keys.js +51 -8
- package/lib/quoxon/launcher.js +103 -0
- package/lib/quoxon/loopCard.js +456 -0
- package/lib/quoxon/loopCards.js +180 -0
- package/lib/quoxon/loopStream.js +87 -0
- package/lib/quoxon/main.js +101 -160
- package/lib/quoxon/markdown.js +45 -21
- package/lib/quoxon/markdownSettle.js +19 -0
- package/lib/quoxon/matrixDomains.json +46 -0
- package/lib/quoxon/mentions.js +90 -0
- package/lib/quoxon/mind.js +103 -0
- package/lib/quoxon/mode.js +46 -0
- package/lib/quoxon/model.js +132 -26
- package/lib/quoxon/modelPrefs.js +345 -0
- package/lib/quoxon/motion/clock.js +3 -2
- package/lib/quoxon/motion/level.js +91 -0
- package/lib/quoxon/mouse.js +222 -0
- package/lib/quoxon/noai.js +57 -0
- package/lib/quoxon/objectiveOffer.js +82 -0
- package/lib/quoxon/objects/away.js +72 -0
- package/lib/quoxon/objects/fetch.js +17 -5
- package/lib/quoxon/objects/fleet.js +149 -0
- package/lib/quoxon/objects/health.js +186 -0
- package/lib/quoxon/objects/index.js +46 -7
- package/lib/quoxon/objects/mirror.js +137 -0
- package/lib/quoxon/objects/model.js +33 -0
- package/lib/quoxon/objects/peek.js +17 -0
- package/lib/quoxon/objects/proof.js +12 -4
- package/lib/quoxon/objects/prove.js +130 -0
- package/lib/quoxon/objects/replay.js +125 -0
- package/lib/quoxon/objects/sitrep.js +58 -0
- package/lib/quoxon/objects/view.js +11 -0
- package/lib/quoxon/onboarding/copy.js +32 -4
- package/lib/quoxon/onboarding/credential.js +17 -4
- package/lib/quoxon/onboarding/dockerOffer.js +45 -0
- package/lib/quoxon/onboarding/failureCause.js +33 -0
- package/lib/quoxon/onboarding/finish.js +15 -0
- package/lib/quoxon/onboarding/index.js +34 -7
- package/lib/quoxon/onboarding/installRunner.js +64 -5
- package/lib/quoxon/onboarding/preflight.js +17 -3
- package/lib/quoxon/onboarding/probe.js +65 -0
- package/lib/quoxon/onboarding/shortcutOffer.js +28 -0
- package/lib/quoxon/onboarding/view.js +76 -19
- package/lib/quoxon/orgLabel.js +10 -0
- package/lib/quoxon/orgSwitch.js +59 -0
- package/lib/quoxon/ownBroadcast.js +22 -0
- package/lib/quoxon/palette.js +43 -0
- package/lib/quoxon/planSteps.js +51 -0
- package/lib/quoxon/print.js +12 -5
- package/lib/quoxon/profileSwitch.js +53 -0
- package/lib/quoxon/promptHistory.js +41 -0
- package/lib/quoxon/providers.js +48 -0
- package/lib/quoxon/qart.js +20 -51
- package/lib/quoxon/qdragon.js +410 -0
- package/lib/quoxon/qdragonFly.js +62 -0
- package/lib/quoxon/qdragonScenes.js +282 -0
- package/lib/quoxon/qeyes.js +213 -0
- package/lib/quoxon/qmotion.js +75 -37
- package/lib/quoxon/render.js +740 -124
- package/lib/quoxon/resend.js +72 -0
- package/lib/quoxon/roster.js +117 -17
- package/lib/quoxon/route.js +6 -5
- package/lib/quoxon/rows.js +954 -0
- package/lib/quoxon/rowsStore.js +80 -0
- package/lib/quoxon/screenContext.js +150 -0
- package/lib/quoxon/screenFrames.js +254 -0
- package/lib/quoxon/serverChip.js +42 -0
- package/lib/quoxon/session.js +182 -2
- package/lib/quoxon/sessionDead.js +17 -0
- package/lib/quoxon/signin.js +125 -0
- package/lib/quoxon/slash/agents.js +112 -0
- package/lib/quoxon/slash/argSources.js +65 -0
- package/lib/quoxon/slash/args.js +65 -0
- package/lib/quoxon/slash/automate.js +89 -0
- package/lib/quoxon/slash/broadcast.js +140 -0
- package/lib/quoxon/slash/broadcastRun.js +108 -0
- package/lib/quoxon/slash/card.js +30 -0
- package/lib/quoxon/slash/challenge.js +114 -0
- package/lib/quoxon/slash/cliCommand.js +94 -0
- package/lib/quoxon/slash/cliLeaves.json +1023 -0
- package/lib/quoxon/slash/compact.js +1 -1
- package/lib/quoxon/slash/confirm.js +30 -0
- package/lib/quoxon/slash/crystallize.js +69 -0
- package/lib/quoxon/slash/dashboard.js +136 -0
- package/lib/quoxon/slash/draft.js +54 -0
- package/lib/quoxon/slash/evidenceCmd.js +34 -0
- package/lib/quoxon/slash/hatWalk.js +206 -0
- package/lib/quoxon/slash/inverses.js +77 -0
- package/lib/quoxon/slash/killer.js +24 -0
- package/lib/quoxon/slash/killerApi.js +16 -0
- package/lib/quoxon/slash/ledger.js +13 -8
- package/lib/quoxon/slash/listView.js +3 -1
- package/lib/quoxon/slash/modelCmd.js +261 -0
- package/lib/quoxon/slash/palette.js +22 -8
- package/lib/quoxon/slash/paletteController.js +117 -7
- package/lib/quoxon/slash/panels.js +21 -6
- package/lib/quoxon/slash/picker.js +20 -2
- package/lib/quoxon/slash/qpeCmd.js +35 -0
- package/lib/quoxon/slash/restart.js +95 -13
- package/lib/quoxon/slash/resume.js +18 -1
- package/lib/quoxon/slash/rowCmd.js +157 -0
- package/lib/quoxon/slash/rowWalk.js +175 -0
- package/lib/quoxon/slash/settings.js +16 -6
- package/lib/quoxon/slash/spawn.js +177 -0
- package/lib/quoxon/slash/stepUp.js +274 -0
- package/lib/quoxon/slash/stepUpCard.js +60 -0
- package/lib/quoxon/slash/stepUpCmds.js +78 -0
- package/lib/quoxon/slash/todoCmd.js +122 -0
- package/lib/quoxon/slash/undoApi.js +38 -0
- package/lib/quoxon/slash/undoCmd.js +49 -0
- package/lib/quoxon/slash/undoWish.js +15 -0
- package/lib/quoxon/slash/watch.js +164 -0
- package/lib/quoxon/slash/wish.js +23 -0
- package/lib/quoxon/slash/wishCmd.js +32 -0
- package/lib/quoxon/slotChat.js +300 -0
- package/lib/quoxon/slotFormat.js +195 -0
- package/lib/quoxon/slotRun.js +131 -0
- package/lib/quoxon/sound/mapping.js +2 -1
- package/lib/quoxon/stray.js +22 -0
- package/lib/quoxon/suggestCommand.js +90 -0
- package/lib/quoxon/term.js +46 -11
- package/lib/quoxon/textBuffer.js +176 -2
- package/lib/quoxon/theme.js +58 -25
- package/lib/quoxon/tips.js +433 -0
- package/lib/quoxon/toolCards.js +30 -0
- package/lib/quoxon/toolError.js +41 -0
- package/lib/quoxon/transcript.js +42 -4
- package/lib/quoxon/web/qface.js +18 -10
- package/lib/quoxon/welcome.js +1 -1
- package/lib/quoxon/whatsnew.js +136 -0
- package/lib/quoxon/whatsnew.jsonl +134 -0
- package/lib/quoxon/windowOrg.js +123 -0
- package/lib/server/devServers.js +137 -0
- package/lib/server/diag.js +129 -0
- package/lib/server/doctor.js +71 -0
- package/lib/server/install.js +149 -0
- package/lib/server/pluginEnable.js +161 -0
- package/lib/server/skew.js +56 -0
- package/lib/server/snapshot.js +131 -0
- package/lib/server/status.js +128 -0
- package/lib/server/sys.js +65 -0
- package/lib/server/uninstall.js +63 -0
- package/lib/server/update.js +234 -0
- package/lib/toolOutputShape.js +315 -0
- package/lib/tui/views/HyperionDeck.jsx +79 -0
- package/lib/tui/views/LucidDeck.jsx +209 -217
- package/package.json +5 -1
- package/vendor/dragonlab/SOURCE +4 -0
- package/vendor/dragonlab/design/cipher/v1/cipher-master.svg +46 -0
- package/vendor/dragonlab/design/cipher/v2/character.mjs +49 -0
- package/vendor/dragonlab/design/cipher/v2/review.mjs +33 -0
- package/vendor/dragonlab/design/cipher/v2/watch.mjs +56 -0
- package/vendor/dragonlab/design/drago/v1/character.mjs +19 -0
- package/vendor/dragonlab/design/drago/v1/drago-master.svg +56 -0
- package/vendor/dragonlab/design/drago/v2/character.mjs +87 -0
- package/vendor/dragonlab/design/drago/v4/character.mjs +67 -0
- package/vendor/dragonlab/design/drago/v4/profile.mjs +35 -0
- package/vendor/dragonlab/design/drago/v4/review.mjs +33 -0
- package/vendor/dragonlab/design/drago/v4/watch.mjs +56 -0
- package/vendor/dragonlab/design/hyperion/v1/hyperion-master.svg +56 -0
- package/vendor/dragonlab/design/hyperion/v2/character.mjs +79 -0
- package/vendor/dragonlab/design/hyperion/v2/review.mjs +33 -0
- package/vendor/dragonlab/design/hyperion/v2/watch.mjs +56 -0
- package/vendor/dragonlab/design/lucid/recovery/arrival.mjs +22 -0
- package/vendor/dragonlab/design/lucid/recovery/character.mjs +106 -0
- package/vendor/dragonlab/design/lucid/recovery/layout.mjs +50 -0
- package/vendor/dragonlab/design/lucid/recovery/lucid-master.svg +78 -0
- package/vendor/dragonlab/design/lucid/recovery/motion.mjs +42 -0
- package/vendor/dragonlab/design/lucid/recovery/render.mjs +121 -0
- package/vendor/dragonlab/design/lucid/recovery/review.mjs +68 -0
- package/vendor/dragonlab/design/lucid/recovery/verify.mjs +84 -0
- package/vendor/dragonlab/design/lucid/recovery/watch.mjs +56 -0
- package/vendor/dragonlab/design/seer/v1/seer-master.svg +53 -0
- package/vendor/dragonlab/design/seer/v2/character.mjs +88 -0
- package/vendor/dragonlab/design/seer/v3/character.mjs +33 -0
- package/vendor/dragonlab/design/seer/v4/character.mjs +66 -0
- package/vendor/dragonlab/design/seer/v4/review.mjs +33 -0
- package/vendor/dragonlab/design/seer/v4/watch.mjs +56 -0
- package/vendor/dragonlab/design/stage3/companion-review.mjs +28 -0
- package/vendor/dragonlab/design/stage3/sentinel-art.js +39 -0
- package/vendor/dragonlab/design/stage3/sentinel-braille.js +216 -0
- package/vendor/dragonlab/design/stage3/sentinel-character.js +257 -0
- package/vendor/dragonlab/design/stage3/sentinel-icon.js +103 -0
- package/vendor/dragonlab/design/stage3/sentinel-warden-eyes.js +95 -0
- package/vendor/dragonlab/design/stage3/svg-coverage.js +148 -0
- package/vendor/dragonlab/factory/svg-rig.mjs +63 -0
- package/vendor/dragonlab/integration/companions-v1/characters.mjs +88 -0
- package/vendor/dragonlab/integration/companions-v1/cipher.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/companion.mjs +63 -0
- package/vendor/dragonlab/integration/companions-v1/drago.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/hyperion.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/lucid.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/seer.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/sentinel.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/view.mjs +28 -0
- package/vendor/dragonlab/integration/hyperion-v3/activity.mjs +76 -0
- package/vendor/dragonlab/lib/power-effects.js +294 -0
- package/vendor/dragonlab/lib/power-registry.js +74 -0
- package/vendor/dragonlab/vendor/quox-dragon/qart.js +348 -0
- package/vendor/dragonlab/vendor/quox-dragon/qeyes.js +213 -0
- package/lib/core/websiteSession.js +0 -148
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Window-scoped org (#1034 root cause). `/org switch` in the TUI used to write the new org and token into the SHARED
|
|
3
|
+
* profile (config.json) and move the shared server session's org, so every other window, script and proof run on the
|
|
4
|
+
* same profile (`-p moo`) silently ran in the switched org. Now the org-scoped token lives in THIS window's memory only:
|
|
5
|
+
*
|
|
6
|
+
* - the switch asks the server for a token without moving the session (`scope: 'window'`); an older server that
|
|
7
|
+
* moves it anyway gets the session put straight back on the profile's org;
|
|
8
|
+
* - the profile, the file and every other process keep the profile's org and token;
|
|
9
|
+
* - when the window token expires it is re-minted for the window's org, never the profile's.
|
|
10
|
+
*
|
|
11
|
+
* `quox org switch` from the shell is still the explicit way to change a profile's default org.
|
|
12
|
+
*/
|
|
13
|
+
import { resolveEndpoint } from '../api/endpoints.js';
|
|
14
|
+
import { RESTART_ORG_ENV } from './slash/restart.js';
|
|
15
|
+
|
|
16
|
+
export { RESTART_ORG_ENV };
|
|
17
|
+
|
|
18
|
+
const expiryOf = (token) => {
|
|
19
|
+
try { const p = JSON.parse(Buffer.from(String(token).split('.')[1], 'base64url').toString('utf8')); return p?.exp ? p.exp * 1000 : null; }
|
|
20
|
+
catch { return null; }
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {object} args
|
|
25
|
+
* @param {object} args.auth the profile auth (createAuth(config))
|
|
26
|
+
* @param {object} args.baseClient a client on the PROFILE identity (createClient(config, auth)), used only to mint
|
|
27
|
+
* @returns {{ auth, switchTo(orgId), current(), _expireForTest() }}
|
|
28
|
+
*/
|
|
29
|
+
export function createWindowOrg({ auth, baseClient, now = () => Date.now() }) {
|
|
30
|
+
let overlay = null; // { id, name, slug, role, access_token, expires_at } for this window only
|
|
31
|
+
const ep = resolveEndpoint('auth.switch-org');
|
|
32
|
+
const profileOrgId = () => { try { return auth.getOrgId(); } catch { return null; } };
|
|
33
|
+
|
|
34
|
+
async function mint(orgId) {
|
|
35
|
+
const data = await baseClient.post(ep.path, { service: ep.service, body: { org_id: orgId, scope: 'window' } });
|
|
36
|
+
if (!data?.org?.id || !data?.access_token) { const e = new Error('The server did not return a token for that org.'); e.code = 'switch_failed'; throw e; }
|
|
37
|
+
if (data.scope !== 'window') {
|
|
38
|
+
// An older server moved the shared session's org (the #1034 leak). Put it back on the profile's org so the other
|
|
39
|
+
// users of this profile do not refresh into the window's org. The token that call returns is discarded.
|
|
40
|
+
const home = profileOrgId();
|
|
41
|
+
if (home && home !== data.org.id) { try { await baseClient.post(ep.path, { service: ep.service, body: { org_id: home } }); } catch { /* best effort */ } }
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
id: data.org.id, name: data.org.name || data.org.slug || data.org.id, slug: data.org.slug || null, role: data.role || null,
|
|
45
|
+
access_token: data.access_token, expires_at: expiryOf(data.access_token) ?? now() + 50 * 60 * 1000,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const over = {
|
|
50
|
+
getTokens() {
|
|
51
|
+
const t = auth.getTokens();
|
|
52
|
+
if (!overlay) return t;
|
|
53
|
+
return { ...(t || {}), access_token: overlay.access_token, expires_at: overlay.expires_at, org_id: overlay.id, org_name: overlay.name, org_slug: overlay.slug, org_role: overlay.role };
|
|
54
|
+
},
|
|
55
|
+
isExpired() { return overlay ? now() > overlay.expires_at : auth.isExpired(); },
|
|
56
|
+
getOrgId(o) { if (o) return o; return overlay ? overlay.id : auth.getOrgId(); },
|
|
57
|
+
async refresh(baseUrl) {
|
|
58
|
+
if (!overlay) return auth.refresh(baseUrl);
|
|
59
|
+
if (auth.isExpired()) await auth.refresh(baseUrl); // the profile's own token, in the profile's org
|
|
60
|
+
overlay = await mint(overlay.id);
|
|
61
|
+
return { access_token: overlay.access_token };
|
|
62
|
+
},
|
|
63
|
+
// A new sign-in or sign-out in this window starts from the profile again.
|
|
64
|
+
async login(...a) { overlay = null; return auth.login(...a); },
|
|
65
|
+
async logout(...a) { overlay = null; return auth.logout(...a); },
|
|
66
|
+
};
|
|
67
|
+
const wrapped = new Proxy(auth, {
|
|
68
|
+
get(t, k) {
|
|
69
|
+
if (Object.prototype.hasOwnProperty.call(over, k)) return over[k];
|
|
70
|
+
const v = t[k];
|
|
71
|
+
return typeof v === 'function' ? v.bind(t) : v;
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
auth: wrapped,
|
|
77
|
+
/** Move THIS window to orgId. Back to the profile's own org just drops the window token. -> { id, name } */
|
|
78
|
+
async switchTo(orgId) {
|
|
79
|
+
if (orgId && orgId === profileOrgId()) {
|
|
80
|
+
overlay = null;
|
|
81
|
+
const a = auth.getTokens() || {};
|
|
82
|
+
return { id: orgId, name: a.org_name || a.org_slug || orgId };
|
|
83
|
+
}
|
|
84
|
+
const m = await mint(orgId);
|
|
85
|
+
overlay = m;
|
|
86
|
+
return { id: m.id, name: m.name };
|
|
87
|
+
},
|
|
88
|
+
/** The window's own org when it differs from the profile's, else null. */
|
|
89
|
+
current() { return overlay ? { id: overlay.id, name: overlay.name } : null; },
|
|
90
|
+
_expireForTest() { if (overlay) overlay.expires_at = now() - 1; },
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The client a TUI window should use: requests carry the window's org token when one is set.
|
|
96
|
+
* -> { client, auth, windowOrg } ready for createApi({ ...this, config }).
|
|
97
|
+
*/
|
|
98
|
+
export function createWindowClient({ config, auth, createClient }) {
|
|
99
|
+
const windowOrg = createWindowOrg({ auth, baseClient: createClient(config, auth) });
|
|
100
|
+
// A client on the SAME user's token for another org, without moving this window (cached; each has its own overlay).
|
|
101
|
+
const scoped = new Map();
|
|
102
|
+
const clientForOrg = async (orgId) => {
|
|
103
|
+
if (scoped.has(orgId)) return scoped.get(orgId);
|
|
104
|
+
let cl;
|
|
105
|
+
if (orgId === (() => { try { return auth.getOrgId(); } catch { return null; } })()) cl = createClient(config, auth);
|
|
106
|
+
else { const sub = createWindowOrg({ auth, baseClient: createClient(config, auth) }); await sub.switchTo(orgId); cl = createClient(config, sub.auth); }
|
|
107
|
+
scoped.set(orgId, cl);
|
|
108
|
+
return cl;
|
|
109
|
+
};
|
|
110
|
+
return { client: createClient(config, windowOrg.auth), auth: windowOrg.auth, windowOrg, clientForOrg };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* /restart hand-over: the predecessor's window org arrives in RESTART_ORG_ENV. Re-enter it in this window, once.
|
|
115
|
+
* -> null (nothing carried) | { id, name } | { error, id }
|
|
116
|
+
*/
|
|
117
|
+
export async function carryOrg(api, env = process.env) {
|
|
118
|
+
const id = env[RESTART_ORG_ENV] || null;
|
|
119
|
+
delete env[RESTART_ORG_ENV];
|
|
120
|
+
if (!id || !api?.switchOrg) return null;
|
|
121
|
+
try { return await api.switchOrg(id); }
|
|
122
|
+
catch (e) { return { error: e?.message || 'refused', id }; }
|
|
123
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// DEVFLEET: the dev-server list behind `quox server list|go|mark` and the TUI /server picker.
|
|
2
|
+
// A dev server is a Bastion host (`quox fleet hosts`) that has a local entry in dev-servers.json
|
|
3
|
+
// (purpose, and optionally project, repo, workdir, ci, user, jump). Bastion supplies address + state;
|
|
4
|
+
// the entry supplies what the server is FOR. Pure functions here; the file read/write is the only IO.
|
|
5
|
+
import { readFileSync, writeFileSync, mkdirSync, renameSync } from 'node:fs';
|
|
6
|
+
import { homedir, hostname } from 'node:os';
|
|
7
|
+
import { join, dirname } from 'node:path';
|
|
8
|
+
import { matchServer, localIps } from '../quoxon/serverChip.js';
|
|
9
|
+
import { resolveEndpoint } from '../api/endpoints.js';
|
|
10
|
+
|
|
11
|
+
const FIELDS = ['purpose', 'project', 'repo', 'workdir', 'ci', 'user', 'jump', 'lastDeploy'];
|
|
12
|
+
const SAFE = /^[A-Za-z0-9._@:\/~+-]+$/;
|
|
13
|
+
|
|
14
|
+
export function registryPath(env = process.env) {
|
|
15
|
+
return join(env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'quox', 'dev-servers.json');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** -> { servers: { [name]: entry } }; a missing file is empty, a corrupt one throws (never clobbered). */
|
|
19
|
+
export function readRegistry(path = registryPath()) {
|
|
20
|
+
let raw;
|
|
21
|
+
try { raw = readFileSync(path, 'utf8'); } catch (e) { if (e.code === 'ENOENT') return { servers: {} }; throw e; }
|
|
22
|
+
const j = JSON.parse(raw);
|
|
23
|
+
return { servers: j && typeof j.servers === 'object' && j.servers ? j.servers : {}, defaults: j && typeof j.defaults === 'object' && j.defaults ? j.defaults : {} };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Additive upsert of one server's entry; unspecified fields are kept. Atomic write. */
|
|
27
|
+
export function markServer(name, fields, path = registryPath()) {
|
|
28
|
+
if (!name || !SAFE.test(name)) throw new Error(`invalid server name: ${name}`);
|
|
29
|
+
const reg = readRegistry(path);
|
|
30
|
+
const next = { ...(reg.servers[name] || {}) };
|
|
31
|
+
for (const k of FIELDS) if (fields[k] != null && fields[k] !== '') next[k] = String(fields[k]);
|
|
32
|
+
reg.servers[name] = next;
|
|
33
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
34
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
35
|
+
writeFileSync(tmp, JSON.stringify(reg, null, 2) + '\n', { mode: 0o600 });
|
|
36
|
+
renameSync(tmp, path);
|
|
37
|
+
return next;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function writeCache(reg, path) {
|
|
41
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
42
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
43
|
+
writeFileSync(tmp, JSON.stringify(reg, null, 2) + '\n', { mode: 0o600 });
|
|
44
|
+
renameSync(tmp, path);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const pickFields = (e) => Object.fromEntries(FIELDS.filter(k => e && e[k] != null && e[k] !== '').map(k => [k, String(e[k])]));
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The registry lives SERVER-SIDE (org-scoped, collector); the local file is an offline cache only.
|
|
51
|
+
* -> { servers, source: 'server' | 'cache', migrated }. A good server read refreshes the cache. If the server
|
|
52
|
+
* has no entries and the cache has some, they are uploaded once (migration). Unreachable or a non-registry
|
|
53
|
+
* answer -> the cache (a corrupt cache throws, never clobbered).
|
|
54
|
+
*/
|
|
55
|
+
export async function loadRegistry({ client, path = registryPath() } = {}) {
|
|
56
|
+
let remote = null;
|
|
57
|
+
let defaults = {}; // local-only preferences (e.g. defaults.jump, the ssh ProxyJump alias): never uploaded, kept across cache refreshes
|
|
58
|
+
try { defaults = readRegistry(path).defaults; } catch { /* corrupt cache: no local defaults */ }
|
|
59
|
+
try {
|
|
60
|
+
const ep = resolveEndpoint('devServerRegistry.list');
|
|
61
|
+
const d = await client.get(ep.path, { service: ep.service, retries: 0, timeout: 5000 });
|
|
62
|
+
if (d && typeof d.servers === 'object' && d.servers && !Array.isArray(d.servers)) remote = d.servers;
|
|
63
|
+
} catch { /* offline: fall back to the cache */ }
|
|
64
|
+
if (!remote) return { servers: readRegistry(path).servers, defaults, source: 'cache', migrated: 0 };
|
|
65
|
+
let migrated = 0;
|
|
66
|
+
let intact = true;
|
|
67
|
+
if (!Object.keys(remote).length) {
|
|
68
|
+
let local = {};
|
|
69
|
+
try { local = readRegistry(path).servers; } catch { /* unreadable cache: nothing to migrate */ }
|
|
70
|
+
for (const [name, e] of Object.entries(local)) {
|
|
71
|
+
try {
|
|
72
|
+
const ep = resolveEndpoint('devServerRegistry.put', { name });
|
|
73
|
+
await client.put(ep.path, { service: ep.service, body: pickFields(e) });
|
|
74
|
+
remote[name] = pickFields(e); migrated++;
|
|
75
|
+
} catch { intact = false; break; /* the cache keeps what did not upload; the next read retries */ }
|
|
76
|
+
}
|
|
77
|
+
// A failed upload must never be followed by a cache overwrite: the cache is then the only copy.
|
|
78
|
+
if (!intact) return { servers: { ...local, ...remote }, defaults, source: 'cache', migrated };
|
|
79
|
+
}
|
|
80
|
+
try { writeCache({ servers: remote, defaults }, path); } catch { /* cache is best effort */ }
|
|
81
|
+
return { servers: remote, defaults, source: 'server', migrated };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Upsert one entry on the server (additive), then refresh the cache. Offline -> throws, nothing written. */
|
|
85
|
+
export async function saveServer({ client, name, fields, path = registryPath() }) {
|
|
86
|
+
if (!name || !SAFE.test(name)) throw new Error(`invalid server name: ${name}`);
|
|
87
|
+
const ep = resolveEndpoint('devServerRegistry.put', { name });
|
|
88
|
+
let r;
|
|
89
|
+
try { r = await client.put(ep.path, { service: ep.service, body: pickFields(fields) }); }
|
|
90
|
+
catch (e) { throw new Error(`could not save to the Quox server (${e?.message || 'failed'}); nothing was recorded`); }
|
|
91
|
+
let entry = r && r.entry && typeof r.entry === 'object' ? pickFields(r.entry) : null;
|
|
92
|
+
if (!entry) { try { entry = markServer(name, fields, path); } catch { entry = pickFields(fields); } }
|
|
93
|
+
else { try { const reg = readRegistry(path); reg.servers[name] = entry; writeCache(reg, path); } catch { /* cache is best effort */ } }
|
|
94
|
+
return entry;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Bastion host list + registry -> rows for each registered server. `here` = { hostname, ips }. */
|
|
98
|
+
export function buildRows(hosts, registry, here = {}) {
|
|
99
|
+
const list = Array.isArray(hosts) ? hosts : [];
|
|
100
|
+
const me = matchServer(list, here);
|
|
101
|
+
return Object.entries(registry?.servers || {}).map(([name, e]) => {
|
|
102
|
+
const h = list.find(x => x && x.id === name) || null;
|
|
103
|
+
return {
|
|
104
|
+
name, purpose: e.purpose || null, project: e.project || null, repo: e.repo || null, workdir: e.workdir || null,
|
|
105
|
+
ci: e.ci || null, lastDeploy: e.lastDeploy || null,
|
|
106
|
+
ip: h?.ip || null,
|
|
107
|
+
registered: Boolean(h),
|
|
108
|
+
reachable: h ? (h.status?.state ?? h.state) === 'active' : false,
|
|
109
|
+
current: Boolean(me && me.name === name),
|
|
110
|
+
user: e.user || null, jump: e.jump || null, defaultJump: registry?.defaults?.jump || null
|
|
111
|
+
};
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const shq = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* What `go` will run. -> { error } | { local: true, cmd, args, cwd, text } | { cmd:'ssh', args, text }.
|
|
119
|
+
* Remote: the user's own ssh config/keys via the jump host alias; nothing is copied anywhere.
|
|
120
|
+
*/
|
|
121
|
+
export function planGo(row) {
|
|
122
|
+
if (!row) return { error: 'unknown server' };
|
|
123
|
+
if (!row.registered) return { error: `${row.name} is not in the Bastion host registry (quox fleet hosts), so go cannot reach it` };
|
|
124
|
+
if (!row.reachable) return { error: `${row.name} is not reachable according to the Bastion registry; try: quox fleet ping ${row.name}` };
|
|
125
|
+
if (row.current) {
|
|
126
|
+
const cwd = row.workdir || process.cwd();
|
|
127
|
+
return { local: true, cmd: 'quox', args: [], cwd, text: `cd ${cwd} && quox (this is ${row.name}; local)` };
|
|
128
|
+
}
|
|
129
|
+
if (!row.ip) return { error: `${row.name} has no address in the Bastion registry` };
|
|
130
|
+
const target = `${row.user || 'control'}@${row.ip}`;
|
|
131
|
+
const remote = `${row.workdir ? `cd ${shq(row.workdir)} && ` : ''}exec quox`;
|
|
132
|
+
const jump = row.jump || row.defaultJump || null; // a per-server --jump, else the registry's defaults.jump, else a direct ssh
|
|
133
|
+
const args = ['-t', ...(jump ? ['-o', `ProxyJump=${jump}`] : []), target, remote];
|
|
134
|
+
return { cmd: 'ssh', args, text: `ssh ${args.slice(0, 4).join(' ')} ${shq(remote)}` };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export const hereIdentity = () => ({ hostname: hostname(), ips: localIps() });
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* C4: `quox server diag` = a redacted support bundle, written locally, never uploaded.
|
|
3
|
+
*
|
|
4
|
+
* Redaction is ALLOWLIST-only, never a denylist (a denylist only knows the secret shapes its author
|
|
5
|
+
* thought of; 2026-08-09 a nested access_token slipped one):
|
|
6
|
+
* - JSON log lines keep only the fields named in LOG_FIELDS; everything else is dropped.
|
|
7
|
+
* - Free-text log lines keep a token only if it has an allowed SHAPE (a plain word, a small number,
|
|
8
|
+
* a timestamp, a URL path with no query and no id-like segment, an HTTP method/status); a key=value
|
|
9
|
+
* keeps its value only when the key is in VALUE_KEYS. Every other token becomes [redacted].
|
|
10
|
+
* - .env: key names only, plus the values of ENV_VALUE_KEYS (non-secret by construction).
|
|
11
|
+
*/
|
|
12
|
+
import { mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
import { envKeyNames, readEnvKeys } from './install.js';
|
|
15
|
+
|
|
16
|
+
export const LOG_FIELDS = ['time', 'timestamp', 'ts', 'level', 'severity', 'service', 'component', 'module', 'msg', 'message', 'event', 'type',
|
|
17
|
+
'status', 'statusCode', 'code', 'method', 'route', 'path', 'duration_ms', 'durationMs', 'latency_ms', 'attempt', 'phase', 'errorName', 'err_name'];
|
|
18
|
+
export const VALUE_KEYS = ['status', 'code', 'method', 'level', 'service', 'port', 'attempt', 'duration', 'ms', 'count', 'exit', 'signal', 'version', 'route', 'path', 'phase', 'state'];
|
|
19
|
+
export const ENV_VALUE_KEYS = ['NODE_ENV', 'LOG_LEVEL', 'QUOXCORE_VERSION', 'COMPOSE_PROJECT_NAME', 'TZ'];
|
|
20
|
+
const R = '[redacted]';
|
|
21
|
+
const TEXT_FIELDS = new Set(['msg', 'message']);
|
|
22
|
+
|
|
23
|
+
const ANSI = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;
|
|
24
|
+
const SHAPES = [
|
|
25
|
+
/^[A-Za-z][A-Za-z'’-]{0,24}$/, // a plain word
|
|
26
|
+
/^-?\d{1,6}(\.\d{1,3})?(ms|s|m|h|%|kb|mb|gb|KB|MB|GB)?$/, // a small number or duration
|
|
27
|
+
/^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2}(:\d{2}(\.\d{1,6})?)?(Z|[+-]\d{2}:?\d{2})?)?$/, // a date / ISO timestamp
|
|
28
|
+
/^\d{2}:\d{2}(:\d{2}(\.\d{1,6})?)?$/, // a clock time
|
|
29
|
+
/^(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)$/,
|
|
30
|
+
/^[[\](){}<>:;,.!?=|/*#&+-]{1,4}$/ // punctuation runs
|
|
31
|
+
];
|
|
32
|
+
const PUNCT_EDGE = /^([("'`[{<]*)(.*?)([)"'`\]}>:;,.!?]*)$/s;
|
|
33
|
+
|
|
34
|
+
/** A URL path with no query; any id-like segment (digits+letters, 12+ chars, hex 8+) is masked. */
|
|
35
|
+
function safePath(p) {
|
|
36
|
+
if (!/^\/[A-Za-z0-9/_.:-]{0,200}$/.test(p)) return null;
|
|
37
|
+
return p.split('/').map((seg) => (seg === '' || SHAPES[0].test(seg) || /^v\d{1,2}$/.test(seg) || /^\d{1,6}$/.test(seg) ? seg : ':id')).join('/');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function scrubToken(tok) {
|
|
41
|
+
const m = PUNCT_EDGE.exec(tok); const [, pre, core, post] = m || ['', '', tok, ''];
|
|
42
|
+
if (!core) return tok;
|
|
43
|
+
const kv = /^([A-Za-z_][\w.-]{0,40})([=:])(.*)$/.exec(core);
|
|
44
|
+
if (kv && !/^\/\//.test(kv[3])) {
|
|
45
|
+
const key = kv[1].toLowerCase().replace(/^.*[._-]/, '');
|
|
46
|
+
const val = kv[3].replace(/^["']|["']$/g, '');
|
|
47
|
+
const keep = VALUE_KEYS.includes(kv[1].toLowerCase()) || VALUE_KEYS.includes(key);
|
|
48
|
+
if (val === '') return `${pre}${kv[1]}${kv[2]}${post}`;
|
|
49
|
+
return `${pre}${kv[1]}${kv[2]}${keep && SHAPES.some((s) => s.test(val)) ? val : keep && safePath(val) ? safePath(val) : R}${post}`;
|
|
50
|
+
}
|
|
51
|
+
if (SHAPES.some((s) => s.test(core))) return `${pre}${core}${post}`;
|
|
52
|
+
if (core.startsWith('/')) { const sp = safePath(core); if (sp) return `${pre}${sp}${post}`; }
|
|
53
|
+
return `${pre}${R}${post}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Free text -> only allowed token shapes survive. */
|
|
57
|
+
export function scrubText(line) {
|
|
58
|
+
return String(line || '').replace(ANSI, '').split(/(\s+)/).map((t) => (/^\s+$/.test(t) || t === '' ? t : scrubToken(t))).join('');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** One log line -> its redacted form. JSON lines keep allowlisted fields only. */
|
|
62
|
+
export function redactLine(line) {
|
|
63
|
+
const raw = String(line || '').replace(ANSI, '');
|
|
64
|
+
// docker compose prefixes "service-1 | "
|
|
65
|
+
const pm = /^(\S+\s+\|\s?)(.*)$/.exec(raw);
|
|
66
|
+
const prefix = pm ? pm[1] : ''; const body = pm ? pm[2] : raw;
|
|
67
|
+
const t = body.trim();
|
|
68
|
+
if (t.startsWith('{')) {
|
|
69
|
+
try {
|
|
70
|
+
const o = JSON.parse(t);
|
|
71
|
+
const kept = {};
|
|
72
|
+
for (const k of LOG_FIELDS) {
|
|
73
|
+
const v = o[k];
|
|
74
|
+
if (v === undefined || v === null || typeof v === 'object') continue;
|
|
75
|
+
if (typeof v === 'string') {
|
|
76
|
+
if (k === 'path' || k === 'route') kept[k] = safePath(v.split('?')[0]) || R;
|
|
77
|
+
else kept[k] = TEXT_FIELDS.has(k) ? scrubText(v) : (SHAPES.some((s) => s.test(v)) ? v : R);
|
|
78
|
+
} else kept[k] = v;
|
|
79
|
+
}
|
|
80
|
+
return prefix + JSON.stringify(kept);
|
|
81
|
+
} catch { /* not JSON after all */ }
|
|
82
|
+
}
|
|
83
|
+
return prefix + scrubText(body);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const INTERESTING = /\b(error|err|fail(ed|ure)?|fatal|exception|panic|warn(ing)?|refused|timeout|unhealthy|denied)\b/i;
|
|
87
|
+
|
|
88
|
+
/** Recent error/warning lines only, newest last, at most `max`. */
|
|
89
|
+
export function recentErrors(text, max = 200) {
|
|
90
|
+
return String(text || '').split('\n').filter((l) => INTERESTING.test(l)).slice(-max).map(redactLine);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Write the bundle. data = { versions, status rows, doctor rows }. Logs come from `docker compose logs`.
|
|
95
|
+
* -> { ok, file, files } ; the directory is tarred into <out>/quox-diag-<stamp>.tar.gz and removed.
|
|
96
|
+
*/
|
|
97
|
+
export async function writeDiagBundle({ install, sys, out, data, now = () => new Date(), services = null, tail = 2000 }) {
|
|
98
|
+
const stamp = now().toISOString().replace(/[:.]/g, '-').replace(/-\d{3}Z$/, 'Z');
|
|
99
|
+
const name = `quox-diag-${stamp}`;
|
|
100
|
+
const dir = join(out, name);
|
|
101
|
+
mkdirSync(join(dir, 'logs'), { recursive: true, mode: 0o700 });
|
|
102
|
+
const files = [];
|
|
103
|
+
const put = (rel, body) => { writeFileSync(join(dir, rel), typeof body === 'string' ? body : JSON.stringify(body, null, 2) + '\n', { mode: 0o600 }); files.push(rel); };
|
|
104
|
+
|
|
105
|
+
put('README.txt', [
|
|
106
|
+
'Quox support bundle (quox server diag). Written on this machine; nothing was uploaded.',
|
|
107
|
+
'Redaction is allowlist-only: JSON log lines keep only named fields; free-text lines keep only plain',
|
|
108
|
+
'words, small numbers, timestamps and id-free URL paths; everything else reads [redacted].',
|
|
109
|
+
'.env: key names only, plus the values of NODE_ENV, LOG_LEVEL, QUOXCORE_VERSION, COMPOSE_PROJECT_NAME, TZ.',
|
|
110
|
+
'Look through it before you share it.', ''
|
|
111
|
+
].join('\n'));
|
|
112
|
+
put('versions.json', data.versions || {});
|
|
113
|
+
put('status.json', data.status || {});
|
|
114
|
+
put('doctor.json', data.doctor || []);
|
|
115
|
+
put('env.json', { keys: envKeyNames(install.repoDir) || [], values: readEnvKeys(install.repoDir, ENV_VALUE_KEYS) });
|
|
116
|
+
|
|
117
|
+
const names = services || (data.status?.services?.ok ? data.status.services.services.map((s) => s.service) : []);
|
|
118
|
+
for (const s of names) {
|
|
119
|
+
if (!/^[\w.-]+$/.test(s)) continue;
|
|
120
|
+
const r = await sys.run('docker', ['compose', '-p', install.project, 'logs', '--no-color', '--tail', String(tail), s], { cwd: install.repoDir });
|
|
121
|
+
const lines = r.code === 0 ? recentErrors(`${r.stdout}\n${r.stderr}`) : [`could not read logs (exit ${r.code})`];
|
|
122
|
+
put(join('logs', `${s}.log`), lines.join('\n') + '\n');
|
|
123
|
+
}
|
|
124
|
+
const file = join(out, `${name}.tar.gz`);
|
|
125
|
+
const t = await sys.run('tar', ['czf', file, '-C', out, name]);
|
|
126
|
+
if (t.code !== 0) return { ok: true, file: dir, files, tarred: false };
|
|
127
|
+
rmSync(dir, { recursive: true, force: true });
|
|
128
|
+
return { ok: true, file, files, tarred: true };
|
|
129
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* C4: `quox server doctor` = the `quox doctor` checks (runDoctorChecks, extracted in f491a2394) plus the
|
|
3
|
+
* server-side checks only the box itself can answer. All read-only. Same row shape as runDoctorChecks:
|
|
4
|
+
* { name, status: 'pass'|'warn'|'fail'|'skip', detail }.
|
|
5
|
+
*/
|
|
6
|
+
import { existsSync } from 'node:fs';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { envKeyNames, readInstalled } from './install.js';
|
|
9
|
+
import { composeServices, diskFree, runningVersion } from './status.js';
|
|
10
|
+
import { latestSnapshot } from './snapshot.js';
|
|
11
|
+
|
|
12
|
+
export const REQUIRED_ENV = ['JWT_SECRET', 'POSTGRES_PASSWORD', 'INTERNAL_SERVICE_KEY'];
|
|
13
|
+
|
|
14
|
+
export async function serverChecks({ install, sys, fetchImpl = globalThis.fetch, urls, backups, now = () => Date.now() }) {
|
|
15
|
+
const out = [];
|
|
16
|
+
const add = (name, status, detail) => out.push({ name, status, detail });
|
|
17
|
+
|
|
18
|
+
const dk = await sys.run('docker', ['info', '--format', '{{.ServerVersion}}']);
|
|
19
|
+
add('Docker engine', dk.code === 0 ? 'pass' : 'fail', dk.code === 0 ? `server ${dk.stdout.trim()}` : 'docker info failed (is the daemon running, is this user in the docker group?)');
|
|
20
|
+
|
|
21
|
+
const svc = await composeServices(install, sys);
|
|
22
|
+
if (!svc.ok) add('Compose services', 'fail', svc.error);
|
|
23
|
+
else {
|
|
24
|
+
const down = svc.services.filter((s) => s.state !== 'running');
|
|
25
|
+
const sick = svc.services.filter((s) => s.state === 'running' && s.health && s.health !== 'healthy');
|
|
26
|
+
add('Compose services', down.length ? 'fail' : sick.length ? 'warn' : 'pass',
|
|
27
|
+
`${svc.services.length - down.length} of ${svc.services.length} running${down.length ? `; not running: ${down.map((s) => s.service).join(', ')}` : ''}${sick.length ? `; unhealthy: ${sick.map((s) => `${s.service} (${s.health})`).join(', ')}` : ''}`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const disk = await diskFree(install.root, sys);
|
|
31
|
+
add('Disk space', !disk.ok ? 'warn' : disk.freeGB < 2 ? 'fail' : disk.freeGB < 5 ? 'warn' : 'pass', disk.ok ? `${disk.freeGB} GB free at ${disk.path}` : disk.error);
|
|
32
|
+
|
|
33
|
+
const keys = envKeyNames(install.repoDir);
|
|
34
|
+
if (!keys) add('Config (.env)', 'fail', `no .env in ${install.repoDir}`);
|
|
35
|
+
else {
|
|
36
|
+
const missing = REQUIRED_ENV.filter((k) => !keys.includes(k));
|
|
37
|
+
add('Config (.env)', missing.length ? 'fail' : 'pass', missing.length ? `missing ${missing.join(', ')}` : `${keys.length} keys, required ones present (values not read)`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const cert = join(install.repoDir, 'ssl', 'quox.crt');
|
|
41
|
+
if (!existsSync(cert)) add('TLS certificate', 'warn', `no ${cert}`);
|
|
42
|
+
else {
|
|
43
|
+
const r = await sys.run('openssl', ['x509', '-enddate', '-noout', '-in', cert]);
|
|
44
|
+
const end = r.code === 0 ? Date.parse(r.stdout.replace(/^notAfter=/, '').trim()) : NaN;
|
|
45
|
+
if (!Number.isFinite(end)) add('TLS certificate', 'warn', 'could not read its expiry (openssl)');
|
|
46
|
+
else {
|
|
47
|
+
const days = Math.floor((end - now()) / 86400000);
|
|
48
|
+
add('TLS certificate', days < 0 ? 'fail' : days < 14 ? 'warn' : 'pass', days < 0 ? `expired ${-days} days ago` : `expires in ${days} days`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const installed = await readInstalled(install, sys);
|
|
53
|
+
add('Checkout', installed.dirty ? 'warn' : installed.dirty === null ? 'warn' : 'pass',
|
|
54
|
+
installed.dirty === null ? 'not a git checkout; quox server update cannot move it' : `${installed.release || '?'} at ${installed.commit}${installed.dirty ? ', with local changes (update will refuse without --force)' : ', clean'}`);
|
|
55
|
+
|
|
56
|
+
const rv = await runningVersion(urls.collector, fetchImpl);
|
|
57
|
+
if (!rv.ok) add('Running build', 'warn', `collector /version: ${rv.error}`);
|
|
58
|
+
else if (rv.stamped === false) add('Running build', 'warn', 'the collector build is unstamped (0.0.0); rebuild with QUOXCORE_VERSION set');
|
|
59
|
+
else {
|
|
60
|
+
const same = installed.commit && rv.commit && (String(rv.commit).startsWith(installed.commit) || String(installed.commit).startsWith(rv.commit));
|
|
61
|
+
add('Running build', same || !installed.commit ? 'pass' : 'warn', same ? `${rv.commit}, matches the checkout` : `running ${rv.commit}, checkout is ${installed.commit} (rebuilt since? quox server update rebuilds)`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const snap = backups ? latestSnapshot(backups) : null;
|
|
65
|
+
if (!snap) add('Backups', 'warn', `no snapshot yet in ${backups}; quox server update takes one first`);
|
|
66
|
+
else {
|
|
67
|
+
const days = Math.floor((now() - Date.parse(snap.createdAt)) / 86400000);
|
|
68
|
+
add('Backups', days > 30 ? 'warn' : 'pass', `latest ${snap.createdAt} (${days} days ago, ${snap.release || snap.commit})`);
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* C4 lifecycle: which install are we talking about, what version is it, what is available.
|
|
3
|
+
*
|
|
4
|
+
* Layout (get.quox.ai -> install.sh -> scripts/setup.sh): $QUOX_DIR/{quox,quoxflow,quoxmcp}; the quox
|
|
5
|
+
* checkout holds docker-compose.yml, VERSION, .env and ssl/. Compose project = the checkout's dir name
|
|
6
|
+
* (setup.sh runs `docker compose` there), unless the running containers say otherwise.
|
|
7
|
+
*/
|
|
8
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
9
|
+
import { homedir } from 'node:os';
|
|
10
|
+
import { join, dirname, basename, resolve } from 'node:path';
|
|
11
|
+
|
|
12
|
+
export const PUBLIC_REPO = 'quoxai/quoxcore';
|
|
13
|
+
export const SIBLINGS = ['quoxflow', 'quoxmcp'];
|
|
14
|
+
|
|
15
|
+
export function backupsRoot(env = process.env, home = homedir()) {
|
|
16
|
+
if (env.QUOX_SERVER_BACKUPS) return resolve(env.QUOX_SERVER_BACKUPS);
|
|
17
|
+
return join(env.XDG_DATA_HOME || join(home, '.local', 'share'), 'quox', 'server-backups');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function normalize(p) {
|
|
21
|
+
if (!p) return null;
|
|
22
|
+
const abs = resolve(p);
|
|
23
|
+
if (existsSync(join(abs, 'docker-compose.yml'))) return { repoDir: abs, root: dirname(abs) };
|
|
24
|
+
if (existsSync(join(abs, 'quox', 'docker-compose.yml'))) return { repoDir: join(abs, 'quox'), root: abs };
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** install.json that scripts/setup.sh writes: { dir } (the install root or the quox checkout). */
|
|
29
|
+
export function installRecordPath(env = process.env, home = homedir()) {
|
|
30
|
+
return join(env.XDG_CONFIG_HOME || join(home, '.config'), 'quox', 'install.json');
|
|
31
|
+
}
|
|
32
|
+
function readRecordedDir(env, home) {
|
|
33
|
+
try { const d = JSON.parse(readFileSync(installRecordPath(env, home), 'utf8'))?.dir; return typeof d === 'string' && d.startsWith('/') ? d : null; } catch { return null; }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Where the install lives: --dir, then QUOX_INSTALL_DIR, then the Getting started journey
|
|
38
|
+
* (~/.config/quox/onboarding.json installDir), then ~/.config/quox/install.json (written by setup.sh), then
|
|
39
|
+
* the running containers' compose label, then the installer's default ~/quox-install.
|
|
40
|
+
* -> { repoDir, root, project, source } | { error }
|
|
41
|
+
*/
|
|
42
|
+
export async function resolveInstall({ dir, env = process.env, sys, journey = null, home = homedir() } = {}) {
|
|
43
|
+
if (dir) {
|
|
44
|
+
const n = normalize(dir);
|
|
45
|
+
if (!n) return { error: `${dir} is not a Quox install (no docker-compose.yml there or in ${join(dir, 'quox')})` };
|
|
46
|
+
return { ...n, project: await projectFor(n.repoDir, sys), source: '--dir' };
|
|
47
|
+
}
|
|
48
|
+
if (env.QUOX_INSTALL_DIR) {
|
|
49
|
+
const n = normalize(env.QUOX_INSTALL_DIR);
|
|
50
|
+
if (n) return { ...n, project: await projectFor(n.repoDir, sys), source: 'QUOX_INSTALL_DIR' };
|
|
51
|
+
}
|
|
52
|
+
let j = journey;
|
|
53
|
+
if (j === null) { try { const { loadJourney } = await import('../quoxon/onboarding/journey.js'); j = loadJourney(); } catch { j = null; } }
|
|
54
|
+
if (j?.installDir) {
|
|
55
|
+
const n = normalize(j.installDir);
|
|
56
|
+
if (n) return { ...n, project: await projectFor(n.repoDir, sys), source: 'Getting started' };
|
|
57
|
+
}
|
|
58
|
+
const rec = normalize(readRecordedDir(env, home));
|
|
59
|
+
if (rec) return { ...rec, project: await projectFor(rec.repoDir, sys), source: 'install.json' };
|
|
60
|
+
if (sys) {
|
|
61
|
+
const r = await sys.run('docker', ['inspect', 'quox-auth', '--format', '{{index .Config.Labels "com.docker.compose.project.working_dir"}}|{{index .Config.Labels "com.docker.compose.project"}}']);
|
|
62
|
+
if (r.code === 0) {
|
|
63
|
+
const [wd, project] = r.stdout.trim().split('|');
|
|
64
|
+
const n = normalize(wd);
|
|
65
|
+
if (n) return { ...n, project: project || basename(n.repoDir), source: 'running containers' };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const def = normalize(join(home, 'quox-install'));
|
|
69
|
+
if (def) return { ...def, project: await projectFor(def.repoDir, sys), source: 'default ~/quox-install' };
|
|
70
|
+
return { error: 'no Quox install found here. Pass --dir <path>, or set QUOX_INSTALL_DIR (setup.sh records it in ~/.config/quox/install.json).' };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function projectFor(repoDir, sys) {
|
|
74
|
+
if (sys) {
|
|
75
|
+
const r = await sys.run('docker', ['inspect', 'quox-auth', '--format', '{{index .Config.Labels "com.docker.compose.project.working_dir"}}|{{index .Config.Labels "com.docker.compose.project"}}']);
|
|
76
|
+
if (r.code === 0) {
|
|
77
|
+
const [wd, project] = r.stdout.trim().split('|');
|
|
78
|
+
if (wd && resolve(wd) === repoDir && project) return project;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return basename(repoDir);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Read named keys from .env. ALLOWLIST: only the keys asked for come back; nothing else is parsed out. */
|
|
85
|
+
export function readEnvKeys(repoDir, keys) {
|
|
86
|
+
const out = {};
|
|
87
|
+
let text = '';
|
|
88
|
+
try { text = readFileSync(join(repoDir, '.env'), 'utf8'); } catch { return out; }
|
|
89
|
+
for (const line of text.split('\n')) {
|
|
90
|
+
const m = /^([A-Z0-9_]+)=(.*)$/.exec(line.trim());
|
|
91
|
+
if (m && keys.includes(m[1])) out[m[1]] = m[2].replace(/^"(.*)"$/, '$1');
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Names only (never values) of every key in .env. */
|
|
97
|
+
export function envKeyNames(repoDir) {
|
|
98
|
+
try {
|
|
99
|
+
return readFileSync(join(repoDir, '.env'), 'utf8').split('\n').map((l) => /^([A-Z0-9_]+)=/.exec(l.trim())?.[1]).filter(Boolean);
|
|
100
|
+
} catch { return null; }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** -> { release, commit, tag, dirty, siblings: { quoxflow: commit|null, ... } } (read-only git). */
|
|
104
|
+
export async function readInstalled({ repoDir, root }, sys) {
|
|
105
|
+
let release = null;
|
|
106
|
+
try { release = readFileSync(join(repoDir, 'VERSION'), 'utf8').trim() || null; } catch { /* no VERSION */ }
|
|
107
|
+
const git = async (dir, args) => { const r = await sys.run('git', ['-C', dir, ...args]); return r.code === 0 ? r.stdout.trim() : null; };
|
|
108
|
+
const commit = await git(repoDir, ['rev-parse', '--short', 'HEAD']);
|
|
109
|
+
const tag = await git(repoDir, ['describe', '--tags', '--exact-match', 'HEAD']);
|
|
110
|
+
const porcelain = await git(repoDir, ['status', '--porcelain', '--untracked-files=no']);
|
|
111
|
+
const siblings = {};
|
|
112
|
+
for (const s of SIBLINGS) {
|
|
113
|
+
const d = join(root, s);
|
|
114
|
+
siblings[s] = existsSync(join(d, '.git')) ? await git(d, ['rev-parse', '--short', 'HEAD']) : null;
|
|
115
|
+
}
|
|
116
|
+
return { release, commit, tag, dirty: porcelain === null ? null : porcelain.length > 0, siblings };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function parseSemver(v) {
|
|
120
|
+
const m = /^v?(\d+)\.(\d+)\.(\d+)$/.exec(String(v || '').trim());
|
|
121
|
+
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
|
|
122
|
+
}
|
|
123
|
+
export function cmpSemver(a, b) {
|
|
124
|
+
const x = parseSemver(a); const y = parseSemver(b);
|
|
125
|
+
if (!x || !y) return null;
|
|
126
|
+
for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] < y[i] ? -1 : 1;
|
|
127
|
+
return 0;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Tags on the public repo (read-only, unauthenticated GitHub API). Status checked before parsing.
|
|
132
|
+
* -> { ok: true, latest, tags } | { ok: false, error }
|
|
133
|
+
*/
|
|
134
|
+
export async function fetchAvailable({ fetchImpl = globalThis.fetch, repo = PUBLIC_REPO, timeoutMs = 8000 } = {}) {
|
|
135
|
+
const url = `https://api.github.com/repos/${repo}/tags?per_page=100`;
|
|
136
|
+
let r;
|
|
137
|
+
try { r = await fetchImpl(url, { headers: { accept: 'application/vnd.github+json', 'user-agent': 'quox-cli' }, signal: AbortSignal.timeout(timeoutMs) }); } catch (e) { return { ok: false, error: `could not reach ${url}: ${e?.message || e}` }; }
|
|
138
|
+
if (!r.ok) return { ok: false, error: `${url} answered HTTP ${r.status}` };
|
|
139
|
+
let body;
|
|
140
|
+
try { body = await r.json(); } catch { return { ok: false, error: `${url} did not return JSON` }; }
|
|
141
|
+
if (!Array.isArray(body)) return { ok: false, error: `${url} returned an unexpected shape` };
|
|
142
|
+
const tags = body.map((t) => t?.name).filter((n) => parseSemver(n)).sort((a, b) => cmpSemver(b, a));
|
|
143
|
+
if (!tags.length) return { ok: false, error: `no release tags on ${repo}` };
|
|
144
|
+
return { ok: true, latest: tags[0], tags };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export const releaseNotesUrl = (tag, repo = PUBLIC_REPO) => `https://github.com/${repo}/releases/tag/${tag}`;
|
|
148
|
+
export const compareUrl = (from, to, repo = PUBLIC_REPO) => `https://github.com/${repo}/compare/${from}...${to}`;
|
|
149
|
+
export const vtag = (v) => (String(v).startsWith('v') ? String(v) : `v${v}`);
|