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,533 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Engine for `quox offload`: run a heavy command on a pool worker VM as if it were local.
|
|
3
|
+
* Every side effect (REST, ssh, rsync, ssh-keygen, fs, sleep) is injected through `deps`
|
|
4
|
+
* so the whole flow is unit-testable without a network or a VM.
|
|
5
|
+
*
|
|
6
|
+
* Security: the worker is reached with a DEDICATED keypair (~/.config/quox/offload_ed25519),
|
|
7
|
+
* generated on first use. Nothing under ~/.ssh is ever read, and key material is never printed.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import os from 'node:os';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import crypto from 'node:crypto';
|
|
14
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
15
|
+
import { poolStatus, poolLease, poolDone } from './vm.js';
|
|
16
|
+
|
|
17
|
+
export const REMOTE_USER = 'hyp';
|
|
18
|
+
export const REMOTE_ROOT = '/home/hyp/offload';
|
|
19
|
+
export const RSYNC_EXCLUDES = ['node_modules', '.git', 'dist', '.env*', '*.pem', '*.key', 'coverage', '.offload-busy'];
|
|
20
|
+
// Documented example env files carry no secrets and tests read them: rsync takes the first matching rule, so these
|
|
21
|
+
// includes go before the '.env*' exclude (BRAIN2 triage 2026-10-15: .env.example never reached the worker).
|
|
22
|
+
export const RSYNC_INCLUDES = ['.env.example', '.env.sample', '.env.template', '*.env.example'];
|
|
23
|
+
export function rsyncFilterArgs() {
|
|
24
|
+
return [...RSYNC_INCLUDES.flatMap((e) => ['--include', e]), ...RSYNC_EXCLUDES.flatMap((e) => ['--exclude', e])];
|
|
25
|
+
}
|
|
26
|
+
export const NO_ROUTE_RE = /no route to host|network is unreachable|connection timed out|operation timed out|could not resolve/i;
|
|
27
|
+
export const NOT_READY_RE = /connection refused|connection reset|connection closed|kex_exchange_identification/i;
|
|
28
|
+
|
|
29
|
+
export class OffloadError extends Error {}
|
|
30
|
+
|
|
31
|
+
export function configDir(home = os.homedir()) { return path.join(home, '.config', 'quox'); }
|
|
32
|
+
export function keyPath(home) { return path.join(configDir(home), 'offload_ed25519'); }
|
|
33
|
+
// A named worker (--worker / QUOX_OFFLOAD_WORKER) keeps its own state, lease and size; unnamed = the shared one.
|
|
34
|
+
export const WORKER_NAME_RE = /^[a-z0-9][a-z0-9-]{0,31}$/;
|
|
35
|
+
export function statePath(home, worker) { return path.join(configDir(home), worker ? `offload-${worker}.json` : 'offload.json'); }
|
|
36
|
+
|
|
37
|
+
export function defaultLessee(env = process.env) {
|
|
38
|
+
return env.QUOX_SESSION || `${env.USER || os.userInfo().username}@${os.hostname()}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Single-quote a string for a POSIX shell. */
|
|
42
|
+
export function shq(s) { return `'${String(s).replace(/'/g, `'\\''`)}'`; }
|
|
43
|
+
|
|
44
|
+
export function loadState(deps) {
|
|
45
|
+
try { return JSON.parse(deps.fs.readFileSync(statePath(deps.home, deps.worker), 'utf8')) || {}; } catch { return {}; }
|
|
46
|
+
}
|
|
47
|
+
export function saveState(deps, st) {
|
|
48
|
+
deps.fs.mkdirSync(configDir(deps.home), { recursive: true, mode: 0o700 });
|
|
49
|
+
deps.fs.writeFileSync(statePath(deps.home, deps.worker), JSON.stringify(st, null, 2), { mode: 0o600 });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Create the dedicated keypair once; returns the PUBLIC key text only. */
|
|
53
|
+
export async function ensureKey(deps) {
|
|
54
|
+
const key = keyPath(deps.home);
|
|
55
|
+
if (!deps.fs.existsSync(key) || !deps.fs.existsSync(`${key}.pub`)) {
|
|
56
|
+
deps.fs.mkdirSync(configDir(deps.home), { recursive: true, mode: 0o700 });
|
|
57
|
+
const r = await deps.run('ssh-keygen', ['-q', '-t', 'ed25519', '-N', '', '-C', 'quox-offload', '-f', key]);
|
|
58
|
+
if (r.status !== 0) throw new OffloadError(`ssh-keygen failed (exit ${r.status}): ssh-keygen -q -t ed25519 -N '' -C quox-offload -f ${key}`);
|
|
59
|
+
try { deps.fs.chmodSync(key, 0o600); } catch { /* best effort */ }
|
|
60
|
+
}
|
|
61
|
+
return String(deps.fs.readFileSync(`${key}.pub`, 'utf8')).trim();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function sshOpts(deps) {
|
|
65
|
+
return [
|
|
66
|
+
'-i', keyPath(deps.home), '-o', 'IdentitiesOnly=yes', '-o', 'BatchMode=yes',
|
|
67
|
+
'-o', 'StrictHostKeyChecking=no', '-o', 'UserKnownHostsFile=/dev/null', '-o', 'LogLevel=ERROR',
|
|
68
|
+
'-o', 'ConnectTimeout=15', '-o', 'ServerAliveInterval=30',
|
|
69
|
+
];
|
|
70
|
+
}
|
|
71
|
+
export function sshArgs(deps, ip, extra = [], remoteCmd) {
|
|
72
|
+
const a = [...sshOpts(deps), ...extra, `${REMOTE_USER}@${ip}`];
|
|
73
|
+
if (remoteCmd !== undefined) a.push(remoteCmd);
|
|
74
|
+
return a;
|
|
75
|
+
}
|
|
76
|
+
const fmtCmd = (cmd, args) => `${cmd} ${args.map((x) => (/[^\w@%+=:,./-]/.test(x) ? shq(x) : x)).join(' ')}`;
|
|
77
|
+
|
|
78
|
+
async function ssh(deps, ip, remoteCmd, extra) {
|
|
79
|
+
const args = sshArgs(deps, ip, extra, remoteCmd);
|
|
80
|
+
const r = await deps.run('ssh', args);
|
|
81
|
+
return { ...r, cmdline: fmtCmd('ssh', args) };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function vmOf(status, vmid) {
|
|
85
|
+
return (status?.vms || []).find((v) => Number(v.vmid) === Number(vmid)) || null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export const HUMAN_LEASE_MSG = 'Leasing a new worker needs a human profile: run `quox -p <human-profile> offload ...` once to create the shared worker; agent sessions then reuse it.';
|
|
89
|
+
|
|
90
|
+
function poolError(e) {
|
|
91
|
+
const msg = String(e?.message || e);
|
|
92
|
+
if (/pool.*(disabled|not enabled)|disabled/i.test(msg)) return new OffloadError(`the VM pool is disabled for this org (enable it with: quox vm pool config --set '{"enabled":1}'): ${msg}`);
|
|
93
|
+
if (/capacity|max_pool|no_capacity|exhaust/i.test(msg)) return new OffloadError(`the VM pool has no capacity right now: ${msg}`);
|
|
94
|
+
return new OffloadError(`pool request failed${e?.status ? ` (HTTP ${e.status})` : ''}: ${msg}`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function leaseError(e) {
|
|
98
|
+
if (e?.status === 403) return new OffloadError(`${HUMAN_LEASE_MSG} (HTTP 403: ${String(e.message || e)})`);
|
|
99
|
+
return poolError(e);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function waitLeased(deps, vmid, { timeoutMs = 20 * 60 * 1000, intervalMs = 5000 } = {}) {
|
|
103
|
+
const t0 = deps.now();
|
|
104
|
+
for (;;) {
|
|
105
|
+
const st = await poolStatus(deps.rest);
|
|
106
|
+
const v = vmOf(st, vmid);
|
|
107
|
+
if (v?.state === 'leased' && v.ip) return v;
|
|
108
|
+
if (v?.state === 'failed') throw new OffloadError(`pool VM ${vmid} failed to build: ${v.last_error || 'unknown error'}`);
|
|
109
|
+
if (deps.now() - t0 > timeoutMs) throw new OffloadError(`pool VM ${vmid} is still ${v?.state || 'missing'} after ${Math.round(timeoutMs / 60000)} minutes; check: quox vm pool status`);
|
|
110
|
+
await deps.sleep(intervalMs);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Reuse this user's warm worker when it is still leased by us, else lease one. Returns {vmid, ip, lessee, expires, reused}. */
|
|
115
|
+
export async function ensureWorker(deps, { cores, memoryGb, diskGb, fresh = false } = {}) {
|
|
116
|
+
const lessee = defaultLessee(deps.env);
|
|
117
|
+
const st = loadState(deps);
|
|
118
|
+
// --fresh: keep the old worker until a NEW lease succeeds (an agent identity cannot lease/release;
|
|
119
|
+
// releasing first would strand it with no worker).
|
|
120
|
+
const oldVmid = st.worker && fresh ? st.worker.vmid : null;
|
|
121
|
+
if (st.worker && !fresh) {
|
|
122
|
+
let status;
|
|
123
|
+
try { status = await poolStatus(deps.rest); } catch (e) { throw poolError(e); }
|
|
124
|
+
const v = vmOf(status, st.worker.vmid);
|
|
125
|
+
// Reuse is judged against the lessee recorded when the worker was leased: the state file is
|
|
126
|
+
// shared by every session of this unix user, but QUOX_SESSION differs per session.
|
|
127
|
+
const owner = st.worker.lessee || lessee;
|
|
128
|
+
const live = v && v.state === 'leased' && v.lessee === owner && v.ip
|
|
129
|
+
&& (!v.lease_expires_at || Date.parse(v.lease_expires_at) > deps.now());
|
|
130
|
+
if (live) {
|
|
131
|
+
const w = { vmid: v.vmid, ip: v.ip, lessee: owner, expires: v.lease_expires_at || null };
|
|
132
|
+
st.worker = { ...st.worker, ...w }; saveState(deps, st);
|
|
133
|
+
return { ...w, reused: true };
|
|
134
|
+
}
|
|
135
|
+
delete st.worker; st.dirs = {}; saveState(deps, st); // stale: a new VM has a clean disk
|
|
136
|
+
}
|
|
137
|
+
const pubkey = await ensureKey(deps);
|
|
138
|
+
let res;
|
|
139
|
+
try {
|
|
140
|
+
res = await poolLease(deps.rest, { lessee, pubkey, cores, memoryGb, diskGb, purpose: 'quox offload' });
|
|
141
|
+
} catch (e) { throw leaseError(e); }
|
|
142
|
+
let vm = res?.lease || null;
|
|
143
|
+
if (!vm && res?.vmid != null) {
|
|
144
|
+
deps.log(`building a worker VM ${res.vmid} (this takes a few minutes)...`);
|
|
145
|
+
try { vm = await waitLeased(deps, res.vmid); } catch (e) { throw e instanceof OffloadError ? e : poolError(e); }
|
|
146
|
+
}
|
|
147
|
+
if (!vm?.vmid) throw new OffloadError(`unexpected lease response from the pool: ${JSON.stringify(res)}`);
|
|
148
|
+
const w = { vmid: vm.vmid, ip: vm.ip || null, lessee, expires: vm.lease_expires_at || vm.expires || null };
|
|
149
|
+
const next = { ...loadState(deps), worker: w, dirs: {} };
|
|
150
|
+
saveState(deps, next);
|
|
151
|
+
if (oldVmid != null && Number(oldVmid) !== Number(vm.vmid)) {
|
|
152
|
+
try { await poolDone(deps.rest, oldVmid); } catch { /* old worker already gone or not releasable */ }
|
|
153
|
+
}
|
|
154
|
+
return { ...w, reused: false };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function refreshIp(deps, w) {
|
|
158
|
+
const v = vmOf(await poolStatus(deps.rest), w.vmid);
|
|
159
|
+
if (!v || v.state !== 'leased') throw new OffloadError(`worker VM ${w.vmid} is no longer leased; run again to lease a new one`);
|
|
160
|
+
if (v.ip) {
|
|
161
|
+
w.ip = v.ip;
|
|
162
|
+
const st = loadState(deps);
|
|
163
|
+
if (st.worker) { st.worker.ip = v.ip; saveState(deps, st); }
|
|
164
|
+
}
|
|
165
|
+
return w.ip;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Make sure ssh reaches the worker. Always re-reads the current IP first; on a no-route style
|
|
170
|
+
* failure refreshes once more. A freshly built VM may refuse connections while sshd starts, so
|
|
171
|
+
* those are retried for up to ~2 minutes.
|
|
172
|
+
*/
|
|
173
|
+
export async function connect(deps, w) {
|
|
174
|
+
await refreshIp(deps, w);
|
|
175
|
+
let refreshed = false;
|
|
176
|
+
for (let attempt = 0; attempt < 24; attempt++) {
|
|
177
|
+
const r = await ssh(deps, w.ip, 'true');
|
|
178
|
+
if (r.status === 0) return w;
|
|
179
|
+
const err = `${r.stderr || ''}${r.stdout || ''}`;
|
|
180
|
+
if (NO_ROUTE_RE.test(err) && !refreshed) {
|
|
181
|
+
refreshed = true;
|
|
182
|
+
const old = w.ip;
|
|
183
|
+
await refreshIp(deps, w);
|
|
184
|
+
if (w.ip !== old) continue;
|
|
185
|
+
}
|
|
186
|
+
if (NOT_READY_RE.test(err) && attempt < 23) { await deps.sleep(5000); continue; }
|
|
187
|
+
throw new OffloadError(`ssh to the worker failed (exit ${r.status}): ${r.cmdline}\n${err.trim()}`);
|
|
188
|
+
}
|
|
189
|
+
throw new OffloadError(`ssh to the worker never became ready: ssh ${REMOTE_USER}@${w.ip}`);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function setupScript(nodeMajor, { docker = false } = {}) {
|
|
193
|
+
return [
|
|
194
|
+
'set -e',
|
|
195
|
+
// set -e does not fire inside an && list or on the left of a pipe, so every install step below ends in || exit
|
|
196
|
+
'set -o pipefail',
|
|
197
|
+
'SUDO=""; [ "$(id -u)" = 0 ] || SUDO="sudo -n"',
|
|
198
|
+
`NEED=${Number(nodeMajor)}`,
|
|
199
|
+
'export DEBIAN_FRONTEND=noninteractive',
|
|
200
|
+
// A fresh pool VM runs apt-daily/unattended-upgrades at boot; wait for it (bounded) instead of failing on the dpkg lock
|
|
201
|
+
'for i in $(seq 1 120); do pgrep -x apt-get >/dev/null || pgrep -f unattended-upgr >/dev/null || pgrep -x dpkg >/dev/null || break; sleep 5; done',
|
|
202
|
+
'APT="$SUDO apt-get -o DPkg::Lock::Timeout=600"',
|
|
203
|
+
'MISSING=""',
|
|
204
|
+
'for p in git rsync build-essential python3 curl ca-certificates; do dpkg -s $p >/dev/null 2>&1 || MISSING="$MISSING $p"; done',
|
|
205
|
+
'if [ -n "$MISSING" ]; then { $APT update -qq && $APT install -y -qq $MISSING; } || { echo "offload setup: apt install of$MISSING failed" >&2; exit 1; }; fi',
|
|
206
|
+
'HAVE=$(node -p "process.versions.node.split(\'.\')[0]" 2>/dev/null || echo none)',
|
|
207
|
+
// a worker already on a NEWER major is fine (callers on this box run 20 and 22); never flip it between majors
|
|
208
|
+
'case "$HAVE" in none|"") HAVE=0;; esac',
|
|
209
|
+
'if [ "$HAVE" -lt "$NEED" ]; then { curl -fsSL https://deb.nodesource.com/setup_$NEED.x | ${SUDO:+$SUDO -E} bash - && $APT install -y -qq nodejs; } || { echo "offload setup: node $NEED install failed" >&2; exit 1; }; fi',
|
|
210
|
+
...(docker ? [
|
|
211
|
+
// --docker: the daemon plus buildx (the sandbox Dockerfiles use COPY --chmod, which needs BuildKit)
|
|
212
|
+
'DMISSING=""',
|
|
213
|
+
'for p in docker.io docker-buildx; do dpkg -s $p >/dev/null 2>&1 || DMISSING="$DMISSING $p"; done',
|
|
214
|
+
'if [ -n "$DMISSING" ]; then { $APT update -qq && $APT install -y -qq $DMISSING; } || { echo "offload setup: docker install failed" >&2; exit 1; }; fi',
|
|
215
|
+
'$SUDO systemctl enable --now docker >/dev/null 2>&1 || true',
|
|
216
|
+
`id -nG ${REMOTE_USER} | grep -qw docker || $SUDO usermod -aG docker ${REMOTE_USER}`,
|
|
217
|
+
] : []),
|
|
218
|
+
// Never record a worker as ready unless the toolchain is really there (2026-10-20: workers marked ready with no npm).
|
|
219
|
+
'command -v npm >/dev/null || { echo "offload setup: npm missing after setup" >&2; exit 1; }',
|
|
220
|
+
'[ "$(node -p "process.versions.node.split(\'.\')[0]")" -ge "$NEED" ] || { echo "offload setup: node is older than $NEED" >&2; exit 1; }',
|
|
221
|
+
`mkdir -p ${REMOTE_ROOT}`,
|
|
222
|
+
].join('\n');
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* --docker: build quox-sandbox:restricted and :standard from <root>/services/sandbox when missing or when the
|
|
227
|
+
* Dockerfile or its build context changed (content hash kept in the image label quox.src). No sandbox dir: no-op.
|
|
228
|
+
* `docker` is the command prefix (`sudo -n docker` on the worker: the group joined this run is not in this session).
|
|
229
|
+
*/
|
|
230
|
+
export function sandboxImagesScript(root, sudo = 'sudo -n') {
|
|
231
|
+
const d = `${root}/services/sandbox`;
|
|
232
|
+
const docker = `${sudo ? `${sudo} ` : ''}docker`;
|
|
233
|
+
return [
|
|
234
|
+
`D=${shq(d)}`,
|
|
235
|
+
'[ -f "$D/Dockerfile.restricted" ] || exit 0',
|
|
236
|
+
'H=$(cd "$D" && find . -type f -not -name "Dockerfile.*" | LC_ALL=C sort | xargs sha256sum | sha256sum | cut -c1-16)',
|
|
237
|
+
'for v in restricted standard; do',
|
|
238
|
+
' [ -f "$D/Dockerfile.$v" ] || continue',
|
|
239
|
+
' S=$H-$(sha256sum "$D/Dockerfile.$v" | cut -c1-16)',
|
|
240
|
+
` CUR=$(${docker} image inspect --format "{{index .Config.Labels \"quox.src\"}}" quox-sandbox:$v 2>/dev/null || true)`,
|
|
241
|
+
' if [ "$CUR" != "$S" ]; then',
|
|
242
|
+
' echo "building quox-sandbox:$v"',
|
|
243
|
+
` ${docker} build -q -t quox-sandbox:$v --label quox.src=$S -f "$D/Dockerfile.$v" "$D" >/dev/null || { echo "build of quox-sandbox:$v failed"; exit 1; }`,
|
|
244
|
+
' fi',
|
|
245
|
+
'done',
|
|
246
|
+
].join('\n');
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export const SETUP_REV = 2;
|
|
250
|
+
|
|
251
|
+
export async function ensureTooling(deps, w, { docker = false } = {}) {
|
|
252
|
+
const st = loadState(deps);
|
|
253
|
+
const major = String(deps.nodeVersion).replace(/^v/, '').split('.')[0];
|
|
254
|
+
// SETUP_REV: bumped when the setup script's guarantees change, so a worker recorded under an older script redoes setup.
|
|
255
|
+
const done = String(st.worker?.setup || '').startsWith(`r${SETUP_REV}:`) ? String(st.worker.setup).slice(`r${SETUP_REV}:`.length) : '';
|
|
256
|
+
// setup is recorded as "<node major>" or "<node major>+docker"; a docker run needs the +docker form
|
|
257
|
+
if (st.worker?.vmid === w.vmid && Number(done.split('+')[0]) >= Number(major) && (!docker || done.endsWith('+docker'))) return false;
|
|
258
|
+
const withDocker = docker || done.endsWith('+docker');
|
|
259
|
+
deps.log(`preparing the worker (node, git, rsync, build tools${withDocker ? ', docker' : ''})...`);
|
|
260
|
+
const r = await ssh(deps, w.ip, `bash -c ${shq(setupScript(major, { docker: withDocker }))}`);
|
|
261
|
+
if (r.status !== 0) throw new OffloadError(`worker setup failed (exit ${r.status}): ${r.cmdline}\n${(r.stderr || '').trim()}`);
|
|
262
|
+
const st2 = loadState(deps);
|
|
263
|
+
if (st2.worker) { st2.worker.setup = `r${SETUP_REV}:` + (withDocker ? `${major}+docker` : major); saveState(deps, st2); }
|
|
264
|
+
return true;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Each synced worktree carries its own node_modules (~2G); dozens of them filled the worker's disk (ENOSPC, 2026-10-15).
|
|
268
|
+
// Before a sync, drop synced dirs idle longer than the limit (dir mtime = last use, touched at every sync), never the
|
|
269
|
+
// one being synced and never one a run is busy in (.offload-busy younger than 6h; an older one is a crashed run).
|
|
270
|
+
export const PRUNE_IDLE_MIN = 120;
|
|
271
|
+
// Under disk pressure (free below minFreePct) the least recently used synced dirs go too, oldest first, never the
|
|
272
|
+
// current one or a busy one, until free space reaches targetFreePct (2026-10-20: ~30 dirs all used within the hour
|
|
273
|
+
// filled the shared worker and the idle rule freed nothing).
|
|
274
|
+
export const PRUNE_MIN_FREE_PCT = 20;
|
|
275
|
+
export const PRUNE_TARGET_FREE_PCT = 35;
|
|
276
|
+
const DF_FREE_PCT = 'df -P . | awk \'NR==2 { gsub("%", "", $5); print 100 - $5 }\'';
|
|
277
|
+
export function pruneScript(root, keep, maxIdleMin = PRUNE_IDLE_MIN, { minFreePct = PRUNE_MIN_FREE_PCT, targetFreePct = PRUNE_TARGET_FREE_PCT, freePctCmd = DF_FREE_PCT } = {}) {
|
|
278
|
+
return [
|
|
279
|
+
`cd ${shq(root)} 2>/dev/null || exit 0`,
|
|
280
|
+
'for d in */; do d=${d%/}; [ -d "$d" ] || continue',
|
|
281
|
+
` [ "$d" = ${shq(keep)} ] && continue`,
|
|
282
|
+
' [ -e "$d/.offload-busy" ] && [ -n "$(find "$d/.offload-busy" -mmin -360 2>/dev/null)" ] && continue',
|
|
283
|
+
` [ -n "$(find "$d" -maxdepth 0 -mmin +${Number(maxIdleMin)} 2>/dev/null)" ] && rm -rf -- "$d"`,
|
|
284
|
+
'done',
|
|
285
|
+
// cache entries nobody has used for a day go too (each worktree's copy is only hard links into these)
|
|
286
|
+
`find ${shq(`${root}/.nm-cache`)} -mindepth 1 -maxdepth 1 -mmin +1440 -exec rm -rf {} + 2>/dev/null; true`,
|
|
287
|
+
`free_pct() { ${freePctCmd}; }`,
|
|
288
|
+
`if [ "$(free_pct)" -lt ${Number(minFreePct)} ]; then`,
|
|
289
|
+
' for d in $(ls -1tr); do [ -d "$d" ] || continue',
|
|
290
|
+
` [ "$(free_pct)" -ge ${Number(targetFreePct)} ] && break`,
|
|
291
|
+
` [ "$d" = ${shq(keep)} ] && continue`,
|
|
292
|
+
' [ -e "$d/.offload-busy" ] && [ -n "$(find "$d/.offload-busy" -mmin -360 2>/dev/null)" ] && continue',
|
|
293
|
+
' rm -rf -- "$d"',
|
|
294
|
+
' done',
|
|
295
|
+
'fi',
|
|
296
|
+
'true',
|
|
297
|
+
].join('\n');
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export async function syncDir(deps, w, dir) {
|
|
301
|
+
const remote = `${REMOTE_ROOT}/${path.basename(dir)}`;
|
|
302
|
+
const pr = await ssh(deps, w.ip, `bash -c ${shq(pruneScript(REMOTE_ROOT, path.basename(dir)))}`);
|
|
303
|
+
if (pr.status !== 0) deps.log(`could not prune idle synced dirs on the worker (exit ${pr.status}); continuing`);
|
|
304
|
+
const args = [
|
|
305
|
+
'-az', '--delete', ...rsyncFilterArgs(),
|
|
306
|
+
'-e', fmtCmd('ssh', sshOpts(deps)),
|
|
307
|
+
`${dir.replace(/\/+$/, '')}/`, `${REMOTE_USER}@${w.ip}:${remote}/`,
|
|
308
|
+
];
|
|
309
|
+
const mk = await ssh(deps, w.ip, `mkdir -p ${shq(remote)} && touch ${shq(remote)} && date +%s > ${shq(`${remote}/.offload-busy`)}`);
|
|
310
|
+
if (mk.status !== 0) throw new OffloadError(`could not create ${remote} on the worker: ${mk.cmdline}\n${(mk.stderr || '').trim()}`);
|
|
311
|
+
const r = await deps.run('rsync', args);
|
|
312
|
+
if (r.status !== 0) throw new OffloadError(`rsync failed (exit ${r.status}): ${fmtCmd('rsync', args)}\n${(r.stderr || '').trim()}`);
|
|
313
|
+
return remote;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// A repo may name sibling checkouts its tests read (skillrights next to the repo, the dev skill pack sources) in
|
|
317
|
+
// .offload-siblings.json: {"siblings":[{"name":"skillrights","path":"~/skillrights"}]}. Each lands at
|
|
318
|
+
// <offload root>/<name>, next to the synced checkout, and only its git-TRACKED files travel (never untracked keys
|
|
319
|
+
// or local state); the usual secret excludes still apply. A missing or non-git sibling is skipped with a log line.
|
|
320
|
+
// A name is one or more '/'-separated segments (claude-root/metron), each a plain file name: no '..', no absolute path.
|
|
321
|
+
const SIBLING_SEGMENT_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
322
|
+
const siblingNameOk = (name) => name.split('/').every((seg) => SIBLING_SEGMENT_RE.test(seg) && !seg.includes('..'));
|
|
323
|
+
export function readSiblings(root, home = os.homedir()) {
|
|
324
|
+
const f = path.join(root, '.offload-siblings.json');
|
|
325
|
+
if (!fs.existsSync(f)) return [];
|
|
326
|
+
const list = JSON.parse(fs.readFileSync(f, 'utf8')).siblings || [];
|
|
327
|
+
return list.map((s) => {
|
|
328
|
+
const name = String(s?.name ?? '');
|
|
329
|
+
if (!siblingNameOk(name)) throw new OffloadError(`bad sibling name in ${f}: ${JSON.stringify(name)}`);
|
|
330
|
+
const p = String(s.path || '').replace(/^~(?=$|\/)/, home);
|
|
331
|
+
return { name, path: path.resolve(p) };
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export async function syncSiblings(deps, w, root) {
|
|
336
|
+
const done = [];
|
|
337
|
+
for (const sib of readSiblings(root, deps.home)) {
|
|
338
|
+
if (!fs.existsSync(sib.path)) { deps.log(`sibling ${sib.name} skipped: ${sib.path} does not exist here`); continue; }
|
|
339
|
+
// git ls-files run in the dir lists its tracked files relative to it, so a repo's subdir works too
|
|
340
|
+
const ls = await deps.run('git', ['-C', sib.path, 'ls-files', '-z']);
|
|
341
|
+
if (ls.status !== 0) { deps.log(`sibling ${sib.name} skipped: ${sib.path} is not in a git checkout (exit ${ls.status})`); continue; }
|
|
342
|
+
const listFile = path.join(os.tmpdir(), `quox-offload-sib-${crypto.randomBytes(4).toString('hex')}`);
|
|
343
|
+
fs.writeFileSync(listFile, ls.stdout || '');
|
|
344
|
+
const remote = `${REMOTE_ROOT}/${sib.name}`;
|
|
345
|
+
try {
|
|
346
|
+
// touch the top-level dir too: the idle prune (pruneScript) judges <offload root>/<first segment> by its mtime
|
|
347
|
+
const top = `${REMOTE_ROOT}/${sib.name.split('/')[0]}`;
|
|
348
|
+
const mk = await ssh(deps, w.ip, `mkdir -p ${shq(remote)} && touch ${shq(remote)} ${shq(top)}`);
|
|
349
|
+
if (mk.status !== 0) throw new OffloadError(`could not create ${remote} on the worker: ${mk.cmdline}\n${(mk.stderr || '').trim()}`);
|
|
350
|
+
const args = ['-az', '--from0', `--files-from=${listFile}`, ...rsyncFilterArgs(), '-e', fmtCmd('ssh', sshOpts(deps)),
|
|
351
|
+
`${sib.path.replace(/\/+$/, '')}/`, `${REMOTE_USER}@${w.ip}:${remote}/`];
|
|
352
|
+
const r = await deps.run('rsync', args);
|
|
353
|
+
if (r.status !== 0) throw new OffloadError(`rsync of sibling ${sib.name} failed (exit ${r.status})\n${(r.stderr || '').trim()}`);
|
|
354
|
+
} finally {
|
|
355
|
+
try { fs.unlinkSync(listFile); } catch { /* best effort */ }
|
|
356
|
+
}
|
|
357
|
+
done.push(sib.name);
|
|
358
|
+
}
|
|
359
|
+
return done;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// One node_modules per distinct lockfile (+ node major), hard-linked into each synced worktree: dozens of worktrees
|
|
363
|
+
// at ~2G each filled the worker's disk twice on 2026-10-15. npm ci runs only on a cache miss and then fills the
|
|
364
|
+
// cache atomically (tmp + rename, so a racing run never sees a half-copied entry).
|
|
365
|
+
export const NM_CACHE_DIR = `${REMOTE_ROOT}/.nm-cache`;
|
|
366
|
+
export function depsInstallScript(dirPath, cacheRoot, key) {
|
|
367
|
+
const c = `${cacheRoot}/${key}`;
|
|
368
|
+
return [
|
|
369
|
+
'set -e',
|
|
370
|
+
`cd ${shq(dirPath)}`,
|
|
371
|
+
`C=${shq(c)}`,
|
|
372
|
+
'if [ -d "$C/node_modules" ]; then',
|
|
373
|
+
' rm -rf node_modules; cp -al "$C/node_modules" node_modules; touch "$C"; echo NM_CACHE_HIT',
|
|
374
|
+
'else',
|
|
375
|
+
' npm ci --no-audit --no-fund',
|
|
376
|
+
` mkdir -p ${shq(cacheRoot)}; T="$C.tmp.$$"; rm -rf "$T"; mkdir -p "$T"`,
|
|
377
|
+
' if cp -al node_modules "$T/node_modules" && mv -T "$T" "$C" 2>/dev/null; then echo NM_CACHE_FILLED; else rm -rf "$T"; echo NM_CACHE_SKIPPED; fi',
|
|
378
|
+
'fi',
|
|
379
|
+
].join('\n');
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** package.json dirs that also have a package-lock.json, up to depth 3, relative to root ('.' for root). */
|
|
383
|
+
export function findLockDirs(deps, root, maxDepth = 3) {
|
|
384
|
+
const out = [];
|
|
385
|
+
const skip = new Set(['node_modules', '.git', 'dist', 'coverage']);
|
|
386
|
+
const walk = (rel, depth) => {
|
|
387
|
+
const abs = path.join(root, rel);
|
|
388
|
+
if (deps.fs.existsSync(path.join(abs, 'package.json')) && deps.fs.existsSync(path.join(abs, 'package-lock.json'))) out.push(rel || '.');
|
|
389
|
+
if (depth >= maxDepth) return;
|
|
390
|
+
let ents = [];
|
|
391
|
+
try { ents = deps.fs.readdirSync(abs, { withFileTypes: true }); } catch { return; }
|
|
392
|
+
for (const e of ents) if (e.isDirectory() && !skip.has(e.name) && !e.name.startsWith('.')) walk(path.join(rel, e.name), depth + 1);
|
|
393
|
+
};
|
|
394
|
+
walk('', 0);
|
|
395
|
+
return out;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
export const hashFile = (deps, f) => crypto.createHash('sha256').update(deps.fs.readFileSync(f)).digest('hex');
|
|
399
|
+
|
|
400
|
+
/** npm ci only where package-lock.json changed since the last sync, or node_modules is missing remotely. */
|
|
401
|
+
export async function installDeps(deps, w, dir, remote, { fresh = false } = {}) {
|
|
402
|
+
const dirs = findLockDirs(deps, dir);
|
|
403
|
+
if (!dirs.length) return [];
|
|
404
|
+
const st = loadState(deps);
|
|
405
|
+
const rec = (st.dirs ||= {})[dir] ||= { remote, hashes: {} };
|
|
406
|
+
const hashes = rec.hashes ||= {};
|
|
407
|
+
const probe = dirs.map((d) => `[ -d ${shq(`${remote}/${d}/node_modules`)} ] || echo ${shq(d)}`).join('; ');
|
|
408
|
+
const pr = await ssh(deps, w.ip, probe);
|
|
409
|
+
const missing = new Set(String(pr.stdout || '').split('\n').map((s) => s.trim()).filter(Boolean));
|
|
410
|
+
const ran = [];
|
|
411
|
+
for (const d of dirs) {
|
|
412
|
+
const h = hashFile(deps, path.join(dir, d, 'package-lock.json'));
|
|
413
|
+
if (!fresh && hashes[d] === h && !missing.has(d)) continue;
|
|
414
|
+
deps.log(`npm ci in ${d}`);
|
|
415
|
+
const major = String(deps.nodeVersion || '').replace(/^v/, '').split('.')[0] || 'x';
|
|
416
|
+
const key = `${String(h).replace(/[^a-f0-9]/gi, '').slice(0, 32)}-node${major}`;
|
|
417
|
+
const r = await ssh(deps, w.ip, `bash -c ${shq(depsInstallScript(`${remote}/${d}`, NM_CACHE_DIR, key))}`);
|
|
418
|
+
if (r.status !== 0) throw new OffloadError(`npm ci failed in ${d} (exit ${r.status}): ${r.cmdline}\n${(r.stderr || r.stdout || '').trim().split('\n').slice(-15).join('\n')}`);
|
|
419
|
+
hashes[d] = h; ran.push(d);
|
|
420
|
+
saveState(deps, st); // persist per dir so an interrupted install resumes
|
|
421
|
+
}
|
|
422
|
+
rec.remote = remote; rec.synced_at = new Date(deps.now()).toISOString();
|
|
423
|
+
saveState(deps, st);
|
|
424
|
+
return ran;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Pool default timeouts (SITE-CLI ruling 2026-10-16, policy rule C: fix the harness, never an assertion). On a
|
|
428
|
+
// shared pool worker vitest's 5 s test / 10 s hook defaults turned ~55 slow-but-green collector tests red per run.
|
|
429
|
+
// A vitest command gets 30 s defaults unless the caller set its own; per-test timeouts still win, no assertion moves.
|
|
430
|
+
const POOL_VITEST_TIMEOUTS = [['--testTimeout', '30000'], ['--hookTimeout', '30000']];
|
|
431
|
+
export function withPoolTimeouts(command) {
|
|
432
|
+
if (!command.some((a) => /(^|\/)vitest(\.m?js)?$/.test(String(a)))) return command;
|
|
433
|
+
const add = POOL_VITEST_TIMEOUTS.filter(([flag]) => !command.some((a) => String(a) === flag || String(a).startsWith(`${flag}=`)))
|
|
434
|
+
.map(([flag, ms]) => `${flag}=${ms}`);
|
|
435
|
+
return [...command, ...add];
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** Full flow. Returns the remote command's exit code. */
|
|
439
|
+
export async function runOffload(deps, { dir, cwd, command, fresh, cores, memoryGb, diskGb, docker = false }) {
|
|
440
|
+
if (!command?.length) throw new OffloadError('no command given: quox offload [options] -- <command...>');
|
|
441
|
+
const root = path.resolve(dir);
|
|
442
|
+
const w = await ensureWorker(deps, { cores, memoryGb, diskGb, fresh });
|
|
443
|
+
deps.log(w.reused ? `reusing worker VM ${w.vmid}` : `leased worker VM ${w.vmid}`);
|
|
444
|
+
await connect(deps, w);
|
|
445
|
+
await ensureTooling(deps, w, { docker });
|
|
446
|
+
const sibs = await syncSiblings(deps, w, root);
|
|
447
|
+
if (sibs.length) deps.log(`synced sibling repos: ${sibs.join(', ')}`);
|
|
448
|
+
const remote = await syncDir(deps, w, root);
|
|
449
|
+
await installDeps(deps, w, root, remote, { fresh });
|
|
450
|
+
if (docker) {
|
|
451
|
+
const r = await ssh(deps, w.ip, `bash -c ${shq(sandboxImagesScript(remote))}`);
|
|
452
|
+
if (r.status !== 0) throw new OffloadError(`sandbox image build failed (exit ${r.status}): ${r.cmdline}\n${((r.stdout || '') + (r.stderr || '')).trim()}`);
|
|
453
|
+
if ((r.stdout || '').trim()) deps.log(r.stdout.trim());
|
|
454
|
+
}
|
|
455
|
+
const rel = path.relative(root, path.resolve(cwd || root));
|
|
456
|
+
const workdir = rel && !rel.startsWith('..') ? `${remote}/${rel}` : remote;
|
|
457
|
+
const envPrefix = deps.env.FORCE_COLOR ? `FORCE_COLOR=${shq(deps.env.FORCE_COLOR)} ` : '';
|
|
458
|
+
const user = withPoolTimeouts(command).map(shq).join(' ');
|
|
459
|
+
const tty = !!deps.isTTY;
|
|
460
|
+
let remoteCmd; let pidFile = null;
|
|
461
|
+
if (tty) {
|
|
462
|
+
remoteCmd = `cd ${shq(workdir)} && ${envPrefix}${user}`;
|
|
463
|
+
} else {
|
|
464
|
+
pidFile = `/tmp/quox-offload-${crypto.randomBytes(4).toString('hex')}.pid`;
|
|
465
|
+
// setsid makes the command its own process group (pgid = pid) so Ctrl-C can kill the whole group.
|
|
466
|
+
remoteCmd = `cd ${shq(workdir)} && exec setsid -w bash -c ${shq(`echo $$ > ${pidFile}; ${envPrefix}exec "$@"`)} _ ${user}`;
|
|
467
|
+
}
|
|
468
|
+
const args = sshArgs(deps, w.ip, tty ? ['-t'] : [], remoteCmd);
|
|
469
|
+
const off = deps.onSigint(async () => {
|
|
470
|
+
if (pidFile) await ssh(deps, w.ip, `kill -INT -- -$(cat ${pidFile}) 2>/dev/null; true`);
|
|
471
|
+
});
|
|
472
|
+
try {
|
|
473
|
+
return await deps.runLive('ssh', args);
|
|
474
|
+
} finally {
|
|
475
|
+
off();
|
|
476
|
+
try { await ssh(deps, w.ip, `rm -f ${pidFile ? `${pidFile} ` : ''}${shq(`${remote}/.offload-busy`)}`); } catch { /* best effort */ }
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
export async function releaseWorker(deps) {
|
|
481
|
+
const st = loadState(deps);
|
|
482
|
+
if (!st.worker) return null;
|
|
483
|
+
const vmid = st.worker.vmid;
|
|
484
|
+
await poolDone(deps.rest, vmid);
|
|
485
|
+
delete st.worker; st.dirs = {};
|
|
486
|
+
saveState(deps, st);
|
|
487
|
+
return vmid;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
export async function workerStatus(deps) {
|
|
491
|
+
const st = loadState(deps);
|
|
492
|
+
if (!st.worker) return { worker: null };
|
|
493
|
+
let live = null;
|
|
494
|
+
try { live = vmOf(await poolStatus(deps.rest), st.worker.vmid); } catch { /* report cached */ }
|
|
495
|
+
return {
|
|
496
|
+
worker: {
|
|
497
|
+
vmid: st.worker.vmid, ip: live?.ip || st.worker.ip, state: live?.state || 'unknown',
|
|
498
|
+
lessee: st.worker.lessee, lease_expires_at: live?.lease_expires_at || st.worker.expires,
|
|
499
|
+
},
|
|
500
|
+
dirs: Object.entries(st.dirs || {}).map(([local, r]) => ({ local, remote: r.remote, synced_at: r.synced_at || null })),
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/** Real side effects. */
|
|
505
|
+
export function realDeps({ rest, env = process.env, log = (m) => process.stderr.write(`offload: ${m}\n`) }) {
|
|
506
|
+
return {
|
|
507
|
+
rest, env, log, fs, home: os.homedir(), nodeVersion: process.version, isTTY: !!process.stdout.isTTY,
|
|
508
|
+
now: () => Date.now(),
|
|
509
|
+
sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
510
|
+
run: (cmd, args) => new Promise((resolve) => {
|
|
511
|
+
const p = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
512
|
+
let stdout = ''; let stderr = '';
|
|
513
|
+
p.stdout.on('data', (d) => { stdout += d; }); p.stderr.on('data', (d) => { stderr += d; });
|
|
514
|
+
p.on('error', (e) => resolve({ status: 127, stdout, stderr: `${stderr}${e.message}` }));
|
|
515
|
+
p.on('close', (code) => resolve({ status: code ?? 1, stdout, stderr }));
|
|
516
|
+
}),
|
|
517
|
+
runLive: (cmd, args) => new Promise((resolve, reject) => {
|
|
518
|
+
const p = spawn(cmd, args, { stdio: 'inherit' });
|
|
519
|
+
p.on('error', (e) => reject(new OffloadError(`${cmd} failed to start: ${e.message}`)));
|
|
520
|
+
p.on('close', (code, sig) => resolve(code ?? (sig ? 128 : 1)));
|
|
521
|
+
}),
|
|
522
|
+
onSigint: (fn) => {
|
|
523
|
+
const h = () => { Promise.resolve(fn()).catch(() => {}); };
|
|
524
|
+
process.on('SIGINT', h);
|
|
525
|
+
return () => process.off('SIGINT', h);
|
|
526
|
+
},
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
export function gitToplevel(cwd) {
|
|
531
|
+
const r = spawnSync('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8' });
|
|
532
|
+
return r.status === 0 ? r.stdout.trim() : cwd;
|
|
533
|
+
}
|
package/lib/core/org-override.js
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
import { QuoxValidationError } from './errors.js';
|
|
19
19
|
|
|
20
20
|
// Commands where --org legitimately means something other than "act in".
|
|
21
|
-
const EXEMPT = new Set(['login', 'logout', 'signup', 'org switch', 'invites list', 'invites accept']);
|
|
21
|
+
export const EXEMPT = new Set(['login', 'logout', 'signup', 'org switch', 'invites list', 'invites accept']);
|
|
22
22
|
|
|
23
23
|
export function commandPath(cmd) {
|
|
24
24
|
const parts = [];
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* orgResolve.js: turn a --org / QUOX_ORG_ID value into an org ID (#1141).
|
|
3
|
+
*
|
|
4
|
+
* The root flag is documented as `--org <slug>`, but the value used to be sent
|
|
5
|
+
* verbatim as the org ID, so `quox --org quox-development vault types` hit
|
|
6
|
+
* /api/orgs/quox-development/... and answered 404 org_not_found.
|
|
7
|
+
*
|
|
8
|
+
* - ID-shaped (ULID or UUID) -> unchanged, no call
|
|
9
|
+
* - the session org's slug or name -> its id, no call
|
|
10
|
+
* - otherwise -> the caller's org list, matched by the
|
|
11
|
+
* same matchOrg the TUI `/org switch` uses (exact slug, then exact id/slug/
|
|
12
|
+
* name, then every word in the name or slug), cached per profile (10 min)
|
|
13
|
+
* - unknown -> error listing the caller's org slugs
|
|
14
|
+
* - ambiguous -> error listing the matches
|
|
15
|
+
*/
|
|
16
|
+
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
17
|
+
import { dirname, join } from 'node:path';
|
|
18
|
+
import { homedir } from 'node:os';
|
|
19
|
+
import { QuoxValidationError } from './errors.js';
|
|
20
|
+
import { matchOrg } from '../quoxon/orgSwitch.js';
|
|
21
|
+
|
|
22
|
+
const ULID = /^[0-9A-HJKMNP-TV-Z]{26}$/i;
|
|
23
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
24
|
+
export const ORG_CACHE_TTL_MS = 10 * 60 * 1000;
|
|
25
|
+
|
|
26
|
+
export const isOrgId = (v) => ULID.test(String(v ?? '').trim()) || UUID.test(String(v ?? '').trim());
|
|
27
|
+
|
|
28
|
+
const defaultCachePath = () => join(homedir(), '.config', 'quox', 'org-cache.json');
|
|
29
|
+
const readCache = (p) => { try { return JSON.parse(readFileSync(p, 'utf8')) || {}; } catch { return {}; } };
|
|
30
|
+
const writeCache = (p, data) => {
|
|
31
|
+
try { mkdirSync(dirname(p), { recursive: true }); writeFileSync(p, JSON.stringify(data), { mode: 0o600 }); } catch { /* cache only */ }
|
|
32
|
+
};
|
|
33
|
+
const label = (o) => o.slug || o.name || o.id;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @param {string} value the --org / QUOX_ORG_ID value
|
|
37
|
+
* @param {object} opts
|
|
38
|
+
* @param {string} [opts.profile] cache key (the effective profile name)
|
|
39
|
+
* @param {object|null} [opts.authData] stored auth (org_id, org_slug, org_name)
|
|
40
|
+
* @param {() => Promise<object[]>} opts.listOrgs the caller's orgs ({ id, slug, name })
|
|
41
|
+
* @param {string} [opts.source] how the value was given, for error text
|
|
42
|
+
* @returns {Promise<string>} the org ID
|
|
43
|
+
*/
|
|
44
|
+
export async function resolveOrgRef(value, { profile = 'default', authData = null, listOrgs, source = '--org', cachePath = defaultCachePath(), now = Date.now } = {}) {
|
|
45
|
+
const ref = String(value ?? '').trim();
|
|
46
|
+
if (!ref || isOrgId(ref)) return value;
|
|
47
|
+
|
|
48
|
+
const low = ref.toLowerCase();
|
|
49
|
+
if (authData?.org_id && (authData.org_slug === ref || String(authData.org_name || '').toLowerCase() === low)) return authData.org_id;
|
|
50
|
+
|
|
51
|
+
const cache = readCache(cachePath);
|
|
52
|
+
const hit = cache?.[profile]?.[low];
|
|
53
|
+
if (hit?.id && now() - (hit.at || 0) < ORG_CACHE_TTL_MS) return hit.id;
|
|
54
|
+
|
|
55
|
+
let list;
|
|
56
|
+
try { list = await listOrgs(); } catch (e) {
|
|
57
|
+
throw new QuoxValidationError(`Could not look up ${source} '${ref}' in your organisations (${e?.message || e}). Pass the org ID instead (\`quox org list\`).`);
|
|
58
|
+
}
|
|
59
|
+
const m = matchOrg(ref, list);
|
|
60
|
+
if (m.ambiguous) {
|
|
61
|
+
throw new QuoxValidationError(`${source} '${ref}' matches more than one of your organisations: ${m.ambiguous.map(label).join(', ')}. Use the full slug or the org ID.`);
|
|
62
|
+
}
|
|
63
|
+
if (!m.org) {
|
|
64
|
+
const yours = (Array.isArray(list) ? list : []).map(label);
|
|
65
|
+
throw new QuoxValidationError(`${source} '${ref}' is not one of your organisations. Yours: ${yours.length ? yours.join(', ') : 'none'}.`);
|
|
66
|
+
}
|
|
67
|
+
cache[profile] = { ...(cache[profile] || {}), [low]: { id: m.org.id, at: now() } };
|
|
68
|
+
writeCache(cachePath, cache);
|
|
69
|
+
return m.org.id;
|
|
70
|
+
}
|