@vellumai/vellum-gateway 0.11.7 → 0.11.8-staging.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +2 -2
- package/ARCHITECTURE.md +3 -3
- package/Dockerfile +1 -0
- package/knip.json +2 -5
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/package.json +1 -0
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/channels.ts +7 -0
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/no-response.ts +84 -0
- package/node_modules/@vellumai/environments/package.json +21 -0
- package/node_modules/@vellumai/environments/src/__tests__/cloud-assistant-hub-url.test.ts +38 -0
- package/node_modules/@vellumai/environments/src/__tests__/install-layout.test.ts +98 -0
- package/node_modules/@vellumai/environments/src/__tests__/package-boundary.test.ts +95 -0
- package/node_modules/@vellumai/environments/src/__tests__/test-path-guard.test.ts +89 -0
- package/node_modules/@vellumai/environments/src/index.ts +22 -0
- package/node_modules/@vellumai/environments/src/install-layout.ts +49 -0
- package/node_modules/@vellumai/environments/src/seeds.ts +99 -0
- package/node_modules/@vellumai/environments/src/shell.test.ts +56 -0
- package/node_modules/@vellumai/environments/src/shell.ts +65 -0
- package/node_modules/@vellumai/environments/src/test-path-guard.ts +113 -0
- package/node_modules/@vellumai/environments/src/types.ts +70 -0
- package/node_modules/@vellumai/environments/tsconfig.json +20 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/package.json +1 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/channels.ts +7 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/no-response.ts +84 -0
- package/node_modules/@vellumai/gateway-client/src/__tests__/guardian-request-contract.test.ts +8 -18
- package/node_modules/@vellumai/gateway-client/src/__tests__/inbound-event-kind.test.ts +46 -0
- package/node_modules/@vellumai/gateway-client/src/__tests__/invite-contract.test.ts +1 -0
- package/node_modules/@vellumai/gateway-client/src/gateway-ipc-contracts.ts +46 -0
- package/node_modules/@vellumai/gateway-client/src/guardian-request-contract.ts +62 -21
- package/node_modules/@vellumai/gateway-client/src/inbound-contract.ts +14 -0
- package/node_modules/@vellumai/gateway-client/src/inbound-event-kind.ts +53 -0
- package/node_modules/@vellumai/gateway-client/src/index.ts +11 -5
- package/node_modules/@vellumai/gateway-client/src/invite-contract.ts +1 -0
- package/node_modules/@vellumai/service-contracts/package.json +1 -0
- package/node_modules/@vellumai/service-contracts/src/channels.ts +7 -0
- package/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
- package/node_modules/@vellumai/service-contracts/src/no-response.ts +84 -0
- package/openapi.json +120 -9
- package/package.json +3 -1
- package/src/__tests__/actor-token-revocation.test.ts +7 -1
- package/src/__tests__/contact-form-kinds.test.ts +19 -0
- package/src/__tests__/contact-handlers.test.ts +40 -0
- package/src/__tests__/contact-prompt-submit.test.ts +213 -6
- package/src/__tests__/contact-record-submit.test.ts +872 -0
- package/src/__tests__/contacts-control-plane-proxy.test.ts +47 -0
- package/src/__tests__/contacts-scoped-route-fallthrough.test.ts +6 -0
- package/src/__tests__/credential-reader.test.ts +91 -58
- package/src/__tests__/credential-watcher-managed-bootstrap.test.ts +39 -40
- package/src/__tests__/credential-watcher.test.ts +3 -69
- package/src/__tests__/delete-contact-if-orphaned.test.ts +231 -0
- package/src/__tests__/dockerfile-workspace-packages.test.ts +38 -0
- package/src/__tests__/guardian-binding-channel-reuse.test.ts +4 -0
- package/src/__tests__/guardian-form-submit.test.ts +211 -0
- package/src/__tests__/guardian-request-delivery-update.test.ts +100 -0
- package/src/__tests__/helpers/contact-route-table.ts +1 -0
- package/src/__tests__/inbound-contact-seed.test.ts +411 -0
- package/src/__tests__/invite-redemption-engine.test.ts +137 -15
- package/src/__tests__/ipc-socket-path.test.ts +15 -7
- package/src/__tests__/live-paths-guard.test.ts +108 -0
- package/src/__tests__/live-voice-websocket.test.ts +7 -1
- package/src/__tests__/pair-device-bound.test.ts +7 -1
- package/src/__tests__/refresh-device-binding.test.ts +7 -1
- package/src/__tests__/slack-normalize.test.ts +7 -7
- package/src/__tests__/slack-reaction-normalize.test.ts +5 -3
- package/src/__tests__/slack-socket-mode-group-dm.test.ts +4 -4
- package/src/__tests__/slack-socket-mode-outbound-root.test.ts +5 -4
- package/src/__tests__/slack-socket-mode-thread-tracking.test.ts +10 -8
- package/src/__tests__/telegram-normalize.test.ts +4 -4
- package/src/__tests__/upsert-verified-contact-channel.test.ts +27 -175
- package/src/__tests__/verification-session-consume.test.ts +4 -0
- package/src/approvals/guardian-request-service.ts +37 -19
- package/src/channels/inbound-event.ts +12 -0
- package/src/credential-reader.ts +58 -42
- package/src/credential-watcher.ts +48 -46
- package/src/db/__tests__/guardian-request-store.test.ts +152 -21
- package/src/db/connection.ts +4 -21
- package/src/db/contact-store.ts +270 -1
- package/src/db/data-migrations/index.ts +2 -0
- package/src/db/data-migrations/m0018-trust-rules-scope-column.ts +47 -0
- package/src/db/guardian-request-store.ts +136 -75
- package/src/db/schema.ts +12 -0
- package/src/db/trust-rule-store.ts +14 -8
- package/src/discord/forward.test.ts +132 -0
- package/src/discord/forward.ts +21 -10
- package/src/discord/gateway-socket.test.ts +164 -3
- package/src/discord/gateway-socket.ts +260 -1
- package/src/discord/intents.test.ts +14 -0
- package/src/discord/intents.ts +13 -1
- package/src/discord/message-schemas.ts +168 -16
- package/src/discord/normalize.test.ts +299 -2
- package/src/discord/normalize.ts +191 -4
- package/src/feature-flag-registry.json +22 -13
- package/src/handlers/handle-inbound.ts +34 -13
- package/src/http/routes/contact-prompt.ts +337 -91
- package/src/http/routes/contacts-control-plane-proxy.ts +403 -88
- package/src/http/routes/contacts-control-plane-route-table.ts +12 -1
- package/src/http/routes/contacts-control-plane-routes.ts +78 -7
- package/src/http/routes/guardian-form-submit.ts +313 -0
- package/src/http/routes/log-export.ts +3 -3
- package/src/http/routes/trust-rules-routes.ts +6 -0
- package/src/http/routes/trust-rules.ts +7 -1
- package/src/index.ts +5 -26
- package/src/ipc/__tests__/guardian-request-routes.test.ts +27 -30
- package/src/ipc/contact-handlers.ts +13 -0
- package/src/ipc/guardian-request-handlers.ts +27 -11
- package/src/paths.ts +33 -4
- package/src/risk/bash-risk-classifier.ts +22 -4
- package/src/risk/command-registry/commands/assistant.ts +11 -0
- package/src/risk/file-risk-classifier.ts +5 -1
- package/src/risk/shell-parser.ts +5 -1
- package/src/risk/trust-rule-cache.ts +59 -6
- package/src/schema.ts +29 -0
- package/src/slack/message-change-normalizer.ts +5 -8
- package/src/slack/normalize.test.ts +19 -20
- package/src/slack/reaction-normalizer.ts +12 -4
- package/src/telegram/normalize.test.ts +1 -1
- package/src/telegram/normalize.ts +0 -1
- package/src/telegram/webhook-manager.ts +3 -0
- package/src/verification/contact-helpers.ts +144 -190
- package/src/avatar-sync/avatar-channel-syncer.ts +0 -78
- package/src/avatar-sync/avatar-sync-watcher.ts +0 -86
- package/src/avatar-sync/slack-avatar-syncer.ts +0 -70
- package/src/avatar-sync/types.ts +0 -16
package/AGENTS.md
CHANGED
|
@@ -217,7 +217,7 @@ Two orthogonal axes, do not conflate them:
|
|
|
217
217
|
- **Admission** (above) — _who gets in the door_. `TRUST_CLASS_RANK` vs `ADMISSION_FLOOR`, enforced across gateway + runtime.
|
|
218
218
|
- **Capabilities** — _what an actor may do once admitted_. Resolved in the runtime, never on the gateway.
|
|
219
219
|
|
|
220
|
-
**Trust classes** (`TrustClass` in `assistant/src/runtime/actor-trust-resolver.ts`) are the _role_, ranked by `TRUST_CLASS_RANK`:
|
|
220
|
+
**Trust classes** (`TrustClass` in `assistant/src/runtime/trust-class.ts`, re-exported from `actor-trust-resolver.ts`) are the _role_, ranked by `TRUST_CLASS_RANK`:
|
|
221
221
|
|
|
222
222
|
| Class | Rank | Meaning |
|
|
223
223
|
| -------------------- | ---- | ------------------------------------------------------------------------ |
|
|
@@ -262,5 +262,5 @@ The gateway owns guardian approval requests and their delivery records: the `gua
|
|
|
262
262
|
|
|
263
263
|
- **Decide atomicity (the core invariant).** `guardian_requests_decide` commits the status CAS (`pending → approved|denied`, first-writer-wins) and the decision's ACL outcome (`activate_member`, `seed_unverified`, `block`, `mint_outbound_session`) in ONE gateway transaction. A failed outcome rolls back the CAS — the request stays `pending` and the guardian can decide again; an `approved` request without its ACL write cannot exist, and deciding twice never applies two outcomes. There is no reopen path.
|
|
264
264
|
- **Schema notes.** There is no `source_type` column — the wire DTO's `sourceType` is derived from `source_channel` (`phone` → voice, `vellum` → desktop, else channel), and the list route translates a `sourceType` filter into `source_channel` predicates. The requester-side conversation is `source_conversation_id`. Request ids are caller-supplied and load-bearing (deterministic access-request ids; `tool_approval` rows reuse the pending-interaction id as PK).
|
|
265
|
-
- **Expiry is daemon-keyed for interaction-bound kinds.** `tool_approval`/`pending_question` requests die with the daemon's in-memory `pendingInteractions` map, so the daemon calls `guardian_requests_expire_interaction_bound` at boot; the gateway never expires them on its own restart. Persistent kinds (`access_request`, `tool_grant_request`) expire
|
|
265
|
+
- **Expiry is daemon-keyed for interaction-bound kinds.** `tool_approval`/`pending_question` requests die with the daemon's in-memory `pendingInteractions` map, so the daemon calls `guardian_requests_expire_interaction_bound` at boot; the gateway never expires them on its own restart, and the boot call never touches persistent kinds. Persistent kinds (`access_request`, `tool_grant_request`) expire via the daemon's 60s sweep: `guardian_requests_list_expired_pending` returns a bounded, read-only batch of past-deadline pending rows, the daemon runs each row's card withdrawal and requester notice, and only then confirms with the per-request `guardian_requests_expire` CAS, so a lost response or crash leaves the row listed for the next round instead of stranding its side effects. The decide CAS carries an `expires_at` guard, so a decision arriving past the deadline loses the arbitration atomically.
|
|
266
266
|
- **Provenance.** The tables moved here from the assistant DB: gateway data migration m0015 backfilled them, and m0016 (checkpoint-gated on m0015, with a final catch-up copy) dropped the assistant-side tables.
|
package/ARCHITECTURE.md
CHANGED
|
@@ -407,7 +407,7 @@ Runtime detects needs_confirmation
|
|
|
407
407
|
|
|
408
408
|
**Guardian-aware routing:** When a guardian binding exists for the channel, the approval flow resolves the sender's actor role (`guardian` vs `non-guardian`). Non-guardian actors have `forcePromptSideEffects` set on the session so all side-effect tools trigger approval prompts regardless of existing allow rules. Approval prompts for non-guardian actions are routed to the guardian's delivery chat (not the requester's chat), and a guardian request (kind `tool_approval`) is recorded in the gateway-owned `guardian_requests` table via `guardian_requests_create`. When the guardian approves or denies, the decision commits through `guardian_requests_decide`, is applied to the underlying run, and the requester's chat is notified of the outcome. Guardian actors follow the standard approval flow. Guardian approval follow-ups also use the conversational engine with role-specific context; `approve_always` is downgraded to `approve_once` for guardian approvals since permanent allow-rules require guardian authority. All guardian state (bindings, challenges, guardian requests) is scoped to the `(assistantId, channel)` pair -- the `assistantId` parameter flows through `handleChannelInbound`, `validateAndConsumeVerification`, `isGuardian`, and `getGuardianBinding`.
|
|
409
409
|
|
|
410
|
-
**Proactive expiry sweep:** The daemon runs a periodic sweep every 60 seconds (`assistant/src/runtime/routes/guardian-expiry-sweep.ts`) that
|
|
410
|
+
**Proactive expiry sweep:** The daemon runs a periodic sweep every 60 seconds (`assistant/src/runtime/routes/guardian-expiry-sweep.ts`) that reads a bounded batch of past-deadline pending requests (`guardian_requests_list_expired_pending`, read-only), fans out card withdrawals and requester expiry notices per row, and only then confirms each with the per-request `guardian_requests_expire` CAS. The status flip is the receipt that the side effects ran: a lost IPC response or a daemon crash leaves the row pending and listed for the next round. Expired approvals close without waiting for follow-up traffic from either party, and a decision arriving past the deadline loses to expiry atomically (the decide CAS guards on `expires_at`).
|
|
411
411
|
|
|
412
412
|
**Gateway-origin ingress contract:** The JWT token exchanged during gateway-to-runtime authentication proves gateway origin (via the `aud=vellum-daemon` claim). No separate header is required.
|
|
413
413
|
|
|
@@ -422,7 +422,7 @@ Runtime detects needs_confirmation
|
|
|
422
422
|
| `assistant/src/runtime/routes/channel-routes.ts` | Integration point: approval interception, actor role resolution, guardian approval routing, deliver-once guard, fail-closed prompt delivery |
|
|
423
423
|
| `assistant/src/runtime/channel-verification-service.ts` | Guardian binding lookups: `isGuardian()`, `getGuardianBinding()` |
|
|
424
424
|
| `assistant/src/memory/delivery-channels.ts` | `claimRunDelivery()` — in-memory deliver-once guard for terminal reply idempotency |
|
|
425
|
-
| `assistant/src/channels/gateway-guardian-requests.ts` | Typed daemon client for the gateway-owned `guardian_requests` lifecycle (`guardian_requests_create` / `_decide` / `
|
|
425
|
+
| `assistant/src/channels/gateway-guardian-requests.ts` | Typed daemon client for the gateway-owned `guardian_requests` lifecycle (`guardian_requests_create` / `_decide` / `_list_expired_pending` / `_expire`) |
|
|
426
426
|
| `assistant/src/runtime/gateway-client.ts` | `deliverApprovalPrompt()` — sends approval payload to gateway |
|
|
427
427
|
| `gateway/src/telegram/send.ts` | `buildInlineKeyboard()` — renders approval actions as Telegram inline buttons |
|
|
428
428
|
| `gateway/src/telegram/normalize.ts` | `callback_query` normalization into `GatewayInboundEvent` (DM-only, drops callbacks without data) |
|
|
@@ -1013,7 +1013,7 @@ When the LLM emits `[ASK_GUARDIAN: question]` during a voice call, the controlle
|
|
|
1013
1013
|
|
|
1014
1014
|
5. **Request-code disambiguation**: When a guardian has multiple pending requests across concurrent calls, they prefix their answer with the 6-character hex request code to indicate which question they are answering. This allows unambiguous routing even when questions arrive on the same channel in quick succession.
|
|
1015
1015
|
|
|
1016
|
-
6. **Expiry sweep**: The daemon's `guardian-expiry-sweep.ts` runs a periodic 60-second sweep that
|
|
1016
|
+
6. **Expiry sweep**: The daemon's `guardian-expiry-sweep.ts` runs a periodic 60-second sweep that lists past-deadline pending requests (`guardian_requests_list_expired_pending`), runs each row's side effects, and confirms each with the per-request `guardian_requests_expire` CAS. Expiry notices are sent to all delivery destinations associated with each expired request (`guardian-action-sweep.ts`).
|
|
1017
1017
|
|
|
1018
1018
|
7. **One store, many kinds**: Voice questions share the gateway's `guardian_requests` store with every other guardian request, distinguished by `kind` — `tool_approval` and `tool_grant_request` rows gate specific tool invocations, `pending_question` rows carry free-form questions from voice calls that require human input to continue the conversation, and `access_request` rows gate unknown-sender access.
|
|
1019
1019
|
|
package/Dockerfile
CHANGED
|
@@ -50,6 +50,7 @@ COPY --from=builder --chown=gateway:gateway /app /app
|
|
|
50
50
|
# Copy source after installing dependencies for better layer caching.
|
|
51
51
|
COPY --chown=gateway:gateway packages/assistant-client /app/packages/assistant-client
|
|
52
52
|
COPY --chown=gateway:gateway packages/ces-client /app/packages/ces-client
|
|
53
|
+
COPY --chown=gateway:gateway packages/environments /app/packages/environments
|
|
53
54
|
COPY --chown=gateway:gateway packages/gateway-client /app/packages/gateway-client
|
|
54
55
|
COPY --chown=gateway:gateway packages/ipc-server-utils /app/packages/ipc-server-utils
|
|
55
56
|
COPY --chown=gateway:gateway packages/service-contracts /app/packages/service-contracts
|
package/knip.json
CHANGED
|
@@ -1,13 +1,10 @@
|
|
|
1
1
|
{
|
|
2
|
-
"entry": [
|
|
3
|
-
"src/**/*.test.ts",
|
|
4
|
-
"src/**/__tests__/**/*.ts",
|
|
5
|
-
"src/cli/index.ts"
|
|
6
|
-
],
|
|
2
|
+
"entry": ["src/**/*.test.ts", "src/**/__tests__/**/*.ts", "src/cli/index.ts"],
|
|
7
3
|
"project": ["src/**/*.ts"],
|
|
8
4
|
"ignoreDependencies": [
|
|
9
5
|
"@vellumai/assistant-client",
|
|
10
6
|
"@vellumai/ces-client",
|
|
7
|
+
"@vellumai/environments",
|
|
11
8
|
"@vellumai/gateway-client",
|
|
12
9
|
"@vellumai/ipc-server-utils",
|
|
13
10
|
"@vellumai/service-contracts",
|
package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/package.json
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
"./client-metadata": "./src/client-metadata.ts",
|
|
12
12
|
"./credential-rpc": "./src/credential-rpc.ts",
|
|
13
13
|
"./ingress": "./src/ingress.ts",
|
|
14
|
+
"./no-response": "./src/no-response.ts",
|
|
14
15
|
"./remote-web-pairing": "./src/remote-web-pairing.ts",
|
|
15
16
|
"./twilio-ingress": "./src/twilio-ingress.ts",
|
|
16
17
|
"./trust-rules": "./src/trust-rules.ts",
|
package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/channels.ts
CHANGED
|
@@ -66,6 +66,13 @@ export function isChannelId(value: unknown): value is ChannelId {
|
|
|
66
66
|
* That irregularity is why this is stated rather than derived from the key,
|
|
67
67
|
* and it is stated here because this file already owns what a channel is.
|
|
68
68
|
*
|
|
69
|
+
* Both senses can hold a user token, which is the sharpest edge. The `slack`
|
|
70
|
+
* integration's persisted token *is* the installer's user token, held on its
|
|
71
|
+
* OAuth connection. `slack_channel` holds an optional `user_token` in the
|
|
72
|
+
* credential store, beside its bot and app tokens. Same words, different
|
|
73
|
+
* homes, and only the second is a credential-store key: a pasted token is
|
|
74
|
+
* always the channel's, because the integration's never leaves the exchange.
|
|
75
|
+
*
|
|
69
76
|
* Deliberately only the key. What fields each credential requires is declared
|
|
70
77
|
* once already, per service, in the gateway's credential specs; restating it
|
|
71
78
|
* here would be a second copy of a different fact.
|
package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/index.ts
CHANGED
|
@@ -27,6 +27,7 @@ export * from "./handles.js";
|
|
|
27
27
|
export * from "./rpc.js";
|
|
28
28
|
export * from "./trust-rules.js";
|
|
29
29
|
export * from "./ingress.js";
|
|
30
|
+
export * from "./no-response.js";
|
|
30
31
|
export * from "./remote-web-pairing.js";
|
|
31
32
|
export * from "./twilio-ingress.js";
|
|
32
33
|
export * from "./url-normalization.js";
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `<no_response/>` sentinel: the cross-service convention for a turn
|
|
3
|
+
* that deliberately produces no user-visible reply.
|
|
4
|
+
*
|
|
5
|
+
* The model emits it, the daemon stamps and strips it, channel delivery
|
|
6
|
+
* suppresses it, and clients hold live-streamed prefixes of it back from
|
|
7
|
+
* display. One definition here keeps every consumer's parsing identical;
|
|
8
|
+
* a hand-rolled copy in any one of them is how the case-sensitivity of a
|
|
9
|
+
* regex drifts.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Matches a message whose entire content is the sentinel. */
|
|
13
|
+
const NO_RESPONSE_ONLY_RE = /^\s*<no_response\s*\/?>\s*$/i;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Whether `text` is nothing but the sentinel: the whole reply is a
|
|
17
|
+
* deliberate non-response, as opposed to real content with an inline
|
|
18
|
+
* sentinel mixed in.
|
|
19
|
+
*/
|
|
20
|
+
export function isNoResponseOnlyText(text: string): boolean {
|
|
21
|
+
return NO_RESPONSE_ONLY_RE.test(text);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Matches every sentinel occurrence for stripping it out of mixed content. */
|
|
25
|
+
export const NO_RESPONSE_INLINE_RE = /<no_response\s*\/?>/gi;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Detection variant without the `g` flag: a `g`-flagged regex is stateful
|
|
29
|
+
* under `.test()` (it resumes from `lastIndex`), so reusing
|
|
30
|
+
* {@link NO_RESPONSE_INLINE_RE} for detection would alternate between
|
|
31
|
+
* matches and misses across calls.
|
|
32
|
+
*/
|
|
33
|
+
const NO_RESPONSE_MARKER_RE = new RegExp(NO_RESPONSE_INLINE_RE.source, "i");
|
|
34
|
+
|
|
35
|
+
/** Whether `text` contains the sentinel anywhere. */
|
|
36
|
+
export function containsNoResponseMarker(text: string): boolean {
|
|
37
|
+
return NO_RESPONSE_MARKER_RE.test(text);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Removes every sentinel occurrence, trimming the leftover whitespace. */
|
|
41
|
+
export function stripNoResponseMarkers(text: string): string {
|
|
42
|
+
return text.replace(NO_RESPONSE_INLINE_RE, "").trim();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const NO_RESPONSE_SENTINEL_FORMS = [
|
|
46
|
+
"<no_response/>",
|
|
47
|
+
"<no_response />",
|
|
48
|
+
"<no_response>",
|
|
49
|
+
] as const;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Whether `text` could still grow into the sentinel, i.e. it is a leading
|
|
53
|
+
* substring of one of its forms. Holding on these keeps a slowly-streamed
|
|
54
|
+
* `<no_response/>` from surfacing as visible partial content.
|
|
55
|
+
*/
|
|
56
|
+
export function isPotentialNoResponsePrefix(text: string): boolean {
|
|
57
|
+
const lower = text.trim().toLowerCase();
|
|
58
|
+
if (lower.length === 0) {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
return NO_RESPONSE_SENTINEL_FORMS.some((sentinel) =>
|
|
62
|
+
sentinel.startsWith(lower),
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Whether `text` carries user-visible content worth acting on now. Returns
|
|
68
|
+
* `false` for empty text, the standalone sentinel, and any prefix that could
|
|
69
|
+
* still complete into one; returns `true` once real content remains after
|
|
70
|
+
* stripping inline sentinels.
|
|
71
|
+
*/
|
|
72
|
+
export function hasDeliverableAssistantText(text: string): boolean {
|
|
73
|
+
const trimmed = text.trim();
|
|
74
|
+
if (trimmed.length === 0) {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
if (NO_RESPONSE_ONLY_RE.test(trimmed)) {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
if (isPotentialNoResponsePrefix(trimmed)) {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
return stripNoResponseMarkers(trimmed).length > 0;
|
|
84
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@vellumai/environments",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"private": true,
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.ts",
|
|
9
|
+
"./seeds": "./src/seeds.ts",
|
|
10
|
+
"./shell": "./src/shell.ts",
|
|
11
|
+
"./test-path-guard": "./src/test-path-guard.ts"
|
|
12
|
+
},
|
|
13
|
+
"scripts": {
|
|
14
|
+
"typecheck": "bunx tsc --noEmit",
|
|
15
|
+
"test": "bun test src/"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"@types/bun": "1.3.11",
|
|
19
|
+
"typescript": "5.9.3"
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pins the environment-to-cloud-hub mapping in one place. Both consumers
|
|
3
|
+
* (the Capacitor shell's `server.url` and the CLI remote-web edge's
|
|
4
|
+
* `hubUrl`) read this helper, so this matrix is the single behavioral pin
|
|
5
|
+
* for the served URLs.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, expect, test } from "bun:test";
|
|
9
|
+
|
|
10
|
+
import { cloudAssistantHubUrl, SEEDS } from "../index.js";
|
|
11
|
+
|
|
12
|
+
describe("cloudAssistantHubUrl", () => {
|
|
13
|
+
test("cloud environments resolve to their own web origin", () => {
|
|
14
|
+
expect(cloudAssistantHubUrl("production")).toBe(
|
|
15
|
+
"https://www.vellum.ai/assistant",
|
|
16
|
+
);
|
|
17
|
+
expect(cloudAssistantHubUrl("staging")).toBe(
|
|
18
|
+
"https://staging-assistant.vellum.ai/assistant",
|
|
19
|
+
);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("every other environment falls back to the dev SPA", () => {
|
|
23
|
+
for (const name of ["dev", "test", "local", "unknown-env", undefined]) {
|
|
24
|
+
expect(cloudAssistantHubUrl(name)).toBe(
|
|
25
|
+
"https://dev-assistant.vellum.ai/assistant",
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("cloud hub URLs derive from the seed web URLs", () => {
|
|
31
|
+
for (const name of ["production", "staging"]) {
|
|
32
|
+
expect(cloudAssistantHubUrl(name)).toBe(
|
|
33
|
+
`${SEEDS[name].webUrl}/assistant`,
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
expect(cloudAssistantHubUrl("local")).toBe(`${SEEDS.dev.webUrl}/assistant`);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for reading an install layout: telling a repo checkout from an
|
|
3
|
+
* installed package, and locating the `assistant` command that ships with an
|
|
4
|
+
* install.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
11
|
+
|
|
12
|
+
import { findAssistantCommand, isRepoCheckoutPath } from "../index.js";
|
|
13
|
+
|
|
14
|
+
let root: string;
|
|
15
|
+
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
root = mkdtempSync(join(tmpdir(), "install-layout-"));
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
rmSync(root, { recursive: true, force: true });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
/** Create `<nodeModulesDir>/.bin/assistant` and return its path. */
|
|
25
|
+
function seedCommand(nodeModulesDir: string): string {
|
|
26
|
+
const binDir = join(nodeModulesDir, ".bin");
|
|
27
|
+
mkdirSync(binDir, { recursive: true });
|
|
28
|
+
const command = join(binDir, "assistant");
|
|
29
|
+
writeFileSync(command, "#!/usr/bin/env bun\n");
|
|
30
|
+
return command;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe("isRepoCheckoutPath", () => {
|
|
34
|
+
test("an installed package is not a checkout", () => {
|
|
35
|
+
expect(
|
|
36
|
+
isRepoCheckoutPath(
|
|
37
|
+
"/Users/x/Library/Application Support/@vellumai/macos/cli/latest/node_modules/@vellumai/assistant/src/index.ts",
|
|
38
|
+
),
|
|
39
|
+
).toBe(false);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("a repo checkout is", () => {
|
|
43
|
+
expect(
|
|
44
|
+
isRepoCheckoutPath("/Users/x/dev/vellum/assistant/src/index.ts"),
|
|
45
|
+
).toBe(true);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("a directory merely named node_modules-ish is not enough", () => {
|
|
49
|
+
expect(
|
|
50
|
+
isRepoCheckoutPath("/Users/x/node_modules_backup/a/src/index.ts"),
|
|
51
|
+
).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe("findAssistantCommand", () => {
|
|
56
|
+
test("finds the command shipped beside an installed package", () => {
|
|
57
|
+
const nodeModules = join(root, "cli", "latest", "node_modules");
|
|
58
|
+
const command = seedCommand(nodeModules);
|
|
59
|
+
const path = join(nodeModules, "@vellumai", "assistant", "src", "index.ts");
|
|
60
|
+
|
|
61
|
+
expect(findAssistantCommand(path)).toBe(command);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("prefers a nested install over a hoisted one", () => {
|
|
65
|
+
const outer = join(root, "node_modules");
|
|
66
|
+
const outerCommand = seedCommand(outer);
|
|
67
|
+
const inner = join(outer, "vellum", "node_modules");
|
|
68
|
+
const innerCommand = seedCommand(inner);
|
|
69
|
+
const path = join(inner, "@vellumai", "assistant", "src", "index.ts");
|
|
70
|
+
|
|
71
|
+
expect(findAssistantCommand(path)).toBe(innerCommand);
|
|
72
|
+
expect(innerCommand).not.toBe(outerCommand);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("falls back to a hoisted install when the nested one has no command", () => {
|
|
76
|
+
const outer = join(root, "node_modules");
|
|
77
|
+
const outerCommand = seedCommand(outer);
|
|
78
|
+
const inner = join(outer, "vellum", "node_modules");
|
|
79
|
+
mkdirSync(inner, { recursive: true });
|
|
80
|
+
const path = join(inner, "@vellumai", "assistant", "src", "index.ts");
|
|
81
|
+
|
|
82
|
+
expect(findAssistantCommand(path)).toBe(outerCommand);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("returns null for a repo checkout", () => {
|
|
86
|
+
expect(
|
|
87
|
+
findAssistantCommand(join(root, "assistant", "src", "index.ts")),
|
|
88
|
+
).toBeNull();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("returns null when the install ships no assistant command", () => {
|
|
92
|
+
const nodeModules = join(root, "node_modules");
|
|
93
|
+
mkdirSync(join(nodeModules, ".bin"), { recursive: true });
|
|
94
|
+
const path = join(nodeModules, "@vellumai", "assistant", "src", "index.ts");
|
|
95
|
+
|
|
96
|
+
expect(findAssistantCommand(path)).toBeNull();
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Package boundary tests for @vellumai/environments.
|
|
3
|
+
*
|
|
4
|
+
* This package is the lowest layer of the shared-packages hierarchy:
|
|
5
|
+
* environment types, constants, and small helpers over node builtins, with no
|
|
6
|
+
* runtime dependencies. It must stay a leaf so any consumer (CLI, assistant,
|
|
7
|
+
* local-mode host) can depend on it without dragging in a dependency tree.
|
|
8
|
+
*
|
|
9
|
+
* Enforces that the package:
|
|
10
|
+
* 1. Imports only node builtins and its own relative modules — no `@vellumai/*`
|
|
11
|
+
* packages and no third-party runtime imports.
|
|
12
|
+
* 2. Declares no runtime `dependencies` (devDependencies only).
|
|
13
|
+
* 3. Is marked `private`.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { describe, expect, test } from "bun:test";
|
|
17
|
+
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
18
|
+
import { join, resolve } from "node:path";
|
|
19
|
+
|
|
20
|
+
const PACKAGE_ROOT = resolve(import.meta.dirname, "../..");
|
|
21
|
+
const SRC_DIR = join(PACKAGE_ROOT, "src");
|
|
22
|
+
|
|
23
|
+
function collectSourceFiles(dir: string): string[] {
|
|
24
|
+
const files: string[] = [];
|
|
25
|
+
for (const entry of readdirSync(dir)) {
|
|
26
|
+
const full = join(dir, entry);
|
|
27
|
+
const stat = statSync(full);
|
|
28
|
+
if (stat.isDirectory()) {
|
|
29
|
+
if (entry === "node_modules" || entry === "__tests__") continue;
|
|
30
|
+
files.push(...collectSourceFiles(full));
|
|
31
|
+
} else if (
|
|
32
|
+
entry.endsWith(".ts") &&
|
|
33
|
+
!entry.endsWith(".test.ts") &&
|
|
34
|
+
!entry.endsWith(".d.ts")
|
|
35
|
+
) {
|
|
36
|
+
files.push(full);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return files;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Matches the module specifier of any `import ... from "<spec>"` /
|
|
44
|
+
* `export ... from "<spec>"` / `require("<spec>")` statement.
|
|
45
|
+
*/
|
|
46
|
+
const IMPORT_SPEC = /(?:from|require\s*\(\s*)["']([^"']+)["']/g;
|
|
47
|
+
|
|
48
|
+
/** A bare specifier is anything that is not a relative or node-builtin import. */
|
|
49
|
+
function isForbiddenSpecifier(spec: string): boolean {
|
|
50
|
+
if (spec.startsWith(".") || spec.startsWith("/")) return false;
|
|
51
|
+
if (spec.startsWith("node:")) return false;
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
describe("package boundary", () => {
|
|
56
|
+
const sourceFiles = collectSourceFiles(SRC_DIR);
|
|
57
|
+
|
|
58
|
+
test("has source files to validate", () => {
|
|
59
|
+
expect(sourceFiles.length).toBeGreaterThan(0);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("imports only node builtins and relative modules", () => {
|
|
63
|
+
const violations: string[] = [];
|
|
64
|
+
|
|
65
|
+
for (const file of sourceFiles) {
|
|
66
|
+
const lines = readFileSync(file, "utf-8").split("\n");
|
|
67
|
+
for (let i = 0; i < lines.length; i++) {
|
|
68
|
+
for (const match of lines[i]!.matchAll(IMPORT_SPEC)) {
|
|
69
|
+
const spec = match[1]!;
|
|
70
|
+
if (isForbiddenSpecifier(spec)) {
|
|
71
|
+
const relative = file.replace(PACKAGE_ROOT + "/", "");
|
|
72
|
+
violations.push(`${relative}:${i + 1}: ${spec}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (violations.length > 0) {
|
|
79
|
+
throw new Error(
|
|
80
|
+
`Found ${violations.length} forbidden import(s) in @vellumai/environments:\n` +
|
|
81
|
+
violations.map((v) => ` - ${v}`).join("\n") +
|
|
82
|
+
"\n\n@vellumai/environments is a dependency-free leaf and must\n" +
|
|
83
|
+
"import only node builtins and its own relative modules.",
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("package.json declares it as private with no runtime dependencies", () => {
|
|
89
|
+
const pkg = JSON.parse(
|
|
90
|
+
readFileSync(join(PACKAGE_ROOT, "package.json"), "utf-8"),
|
|
91
|
+
);
|
|
92
|
+
expect(pkg.private).toBe(true);
|
|
93
|
+
expect(pkg.dependencies ?? {}).toEqual({});
|
|
94
|
+
});
|
|
95
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { mkdirSync, mkdtempSync, rmSync, symlinkSync } from "node:fs";
|
|
2
|
+
import { homedir, tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { describe, expect, test } from "bun:test";
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
assertTestPathIsEphemeral,
|
|
8
|
+
canonicalizePathThroughExistingParent,
|
|
9
|
+
isBunTestProcess,
|
|
10
|
+
} from "../test-path-guard.js";
|
|
11
|
+
|
|
12
|
+
const OPTIONS = {
|
|
13
|
+
allowEnvVar: "TEST_PATH_GUARD_ALLOW",
|
|
14
|
+
runHint: "Run tests from the package root.",
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
describe("isBunTestProcess", () => {
|
|
18
|
+
test("detects this bun test run", () => {
|
|
19
|
+
expect(isBunTestProcess()).toBe(true);
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe("canonicalizePathThroughExistingParent", () => {
|
|
24
|
+
test("resolves symlinks on the deepest existing ancestor", () => {
|
|
25
|
+
const root = mkdtempSync(join(tmpdir(), "test-path-guard-"));
|
|
26
|
+
try {
|
|
27
|
+
const target = join(root, "target");
|
|
28
|
+
mkdirSync(target);
|
|
29
|
+
const link = join(root, "link");
|
|
30
|
+
symlinkSync(target, link, "dir");
|
|
31
|
+
expect(
|
|
32
|
+
canonicalizePathThroughExistingParent(join(link, "missing", "leaf")),
|
|
33
|
+
).toBe(
|
|
34
|
+
join(canonicalizePathThroughExistingParent(target), "missing", "leaf"),
|
|
35
|
+
);
|
|
36
|
+
} finally {
|
|
37
|
+
rmSync(root, { recursive: true, force: true });
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("returns the input path when no ancestor exists", () => {
|
|
42
|
+
expect(canonicalizePathThroughExistingParent("/nonexistent-root-x/y")).toBe(
|
|
43
|
+
"/nonexistent-root-x/y",
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("assertTestPathIsEphemeral", () => {
|
|
49
|
+
test("allows paths under tmpdir, existing or not", () => {
|
|
50
|
+
expect(() =>
|
|
51
|
+
assertTestPathIsEphemeral(join(tmpdir(), "guard-missing", "x"), OPTIONS),
|
|
52
|
+
).not.toThrow();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("refuses paths outside tmpdir, naming the escape hatch and hint", () => {
|
|
56
|
+
delete process.env[OPTIONS.allowEnvVar];
|
|
57
|
+
const dir = join(homedir(), "test-path-guard-live");
|
|
58
|
+
expect(() => assertTestPathIsEphemeral(dir, OPTIONS)).toThrow(
|
|
59
|
+
/Refusing to use[\s\S]*package root[\s\S]*TEST_PATH_GUARD_ALLOW/,
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("refuses a tmpdir symlink that resolves outside tmpdir", () => {
|
|
64
|
+
delete process.env[OPTIONS.allowEnvVar];
|
|
65
|
+
const link = join(tmpdir(), `test-path-guard-escape-${process.pid}`);
|
|
66
|
+
rmSync(link, { force: true });
|
|
67
|
+
symlinkSync(homedir(), link);
|
|
68
|
+
try {
|
|
69
|
+
expect(() => assertTestPathIsEphemeral(link, OPTIONS)).toThrow(
|
|
70
|
+
/Refusing to use/,
|
|
71
|
+
);
|
|
72
|
+
} finally {
|
|
73
|
+
rmSync(link, { force: true });
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("the allow env var bypasses the guard, per call", () => {
|
|
78
|
+
const dir = join(homedir(), "test-path-guard-optout");
|
|
79
|
+
process.env[OPTIONS.allowEnvVar] = "1";
|
|
80
|
+
try {
|
|
81
|
+
expect(() => assertTestPathIsEphemeral(dir, OPTIONS)).not.toThrow();
|
|
82
|
+
} finally {
|
|
83
|
+
delete process.env[OPTIONS.allowEnvVar];
|
|
84
|
+
}
|
|
85
|
+
expect(() => assertTestPathIsEphemeral(dir, OPTIONS)).toThrow(
|
|
86
|
+
/Refusing to use/,
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vellumai/environments: the single source of truth for how a Vellum install
|
|
3
|
+
* is laid out and which deployment environment it belongs to.
|
|
4
|
+
*
|
|
5
|
+
* Two things live here:
|
|
6
|
+
*
|
|
7
|
+
* - The known deployment environments (names, platform/web URLs, per-service
|
|
8
|
+
* port blocks). The Swift client mirrors the same set in
|
|
9
|
+
* `VellumEnvironment.swift`.
|
|
10
|
+
* - Install layout: whether a runtime is a repo checkout or an installed
|
|
11
|
+
* package, and where the `assistant` command it ships lives.
|
|
12
|
+
*
|
|
13
|
+
* Consumed by the CLI, the assistant daemon, and the local-mode host library
|
|
14
|
+
* so each answer is defined exactly once on the TS side.
|
|
15
|
+
*
|
|
16
|
+
* This is the lowest layer of the shared-packages hierarchy and stays a leaf:
|
|
17
|
+
* types, constants, and small helpers over node builtins, with no runtime
|
|
18
|
+
* dependencies of its own. See `__tests__/package-boundary.test.ts`.
|
|
19
|
+
*/
|
|
20
|
+
export * from "./types.js";
|
|
21
|
+
export * from "./seeds.js";
|
|
22
|
+
export * from "./install-layout.js";
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading the layout a Vellum runtime is installed in.
|
|
3
|
+
*
|
|
4
|
+
* The daemon is started either from a developer's repo checkout or from an
|
|
5
|
+
* installed npm package (`<install>/node_modules/@vellumai/assistant/src/
|
|
6
|
+
* index.ts`, which is what the desktop app runs). The CLI needs the layout to
|
|
7
|
+
* decide whether a launch is a dev run and what to put on the daemon's PATH;
|
|
8
|
+
* the daemon needs it to decide what to install as the `assistant` command.
|
|
9
|
+
* Both ask the same two questions, so they ask them here.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { existsSync } from "node:fs";
|
|
13
|
+
import { join, sep } from "node:path";
|
|
14
|
+
|
|
15
|
+
const NODE_MODULES_SEGMENT = `${sep}node_modules${sep}`;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Whether `path` sits in a developer's repo checkout rather than inside an
|
|
19
|
+
* installed package.
|
|
20
|
+
*/
|
|
21
|
+
export function isRepoCheckoutPath(path: string): boolean {
|
|
22
|
+
return !path.includes(NODE_MODULES_SEGMENT);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The `assistant` command shipped with the install that contains `path`, or
|
|
27
|
+
* null when there is none (a repo checkout, or an install whose dependency
|
|
28
|
+
* graph omits the package declaring the bin).
|
|
29
|
+
*
|
|
30
|
+
* Searches innermost `node_modules` outward, since a nested install shadows a
|
|
31
|
+
* hoisted one.
|
|
32
|
+
*/
|
|
33
|
+
export function findAssistantCommand(path: string): string | null {
|
|
34
|
+
let index = path.lastIndexOf(NODE_MODULES_SEGMENT);
|
|
35
|
+
|
|
36
|
+
while (index !== -1) {
|
|
37
|
+
const nodeModulesDir = path.slice(
|
|
38
|
+
0,
|
|
39
|
+
index + NODE_MODULES_SEGMENT.length - 1,
|
|
40
|
+
);
|
|
41
|
+
const command = join(nodeModulesDir, ".bin", "assistant");
|
|
42
|
+
if (existsSync(command)) {
|
|
43
|
+
return command;
|
|
44
|
+
}
|
|
45
|
+
index = path.lastIndexOf(NODE_MODULES_SEGMENT, index - 1);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return null;
|
|
49
|
+
}
|