@checkstack/script-packages-frontend 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +129 -0
- package/package.json +39 -0
- package/src/components/PackageNameCombobox.tsx +153 -0
- package/src/components/PackageVersionCombobox.tsx +166 -0
- package/src/components/ScriptPackagesMenuItems.tsx +35 -0
- package/src/components/version-autofill.test.ts +43 -0
- package/src/components/version-autofill.ts +34 -0
- package/src/hooks/useDebouncedValue.ts +18 -0
- package/src/index.tsx +45 -0
- package/src/pages/ScriptPackagesSettingsPage.tsx +783 -0
- package/src/useScriptPackageTypeAcquisition.ts +84 -0
- package/tsconfig.json +20 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# @checkstack/script-packages-frontend
|
|
2
|
+
|
|
3
|
+
## 0.2.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- b995afb: Fix two Script Packages bugs: empty-allowlist installs and read-only Advanced settings.
|
|
8
|
+
|
|
9
|
+
- Install now: clicking "Install now" with no enabled packages no longer fails with ENOENT and an `error` install state. With an empty dependency set `bun install` writes no `bun.lock`, so the central resolver previously threw reading it. The resolver now short-circuits an empty (or all-disabled) allowlist to an empty resolved set, ending the install in `ready` at 0.0 MB with the deterministic empty-lockfile hash. No subprocess or registry call is made in that case.
|
|
10
|
+
- Advanced settings: the registry URL, "ignore install scripts" toggle, write-only auth token, size guardrail thresholds, and active storage backend are now editable in the Script Packages settings page (previously read-only displays) and wired to the existing `setRegistryConfig` / `setSizeCapConfig` / `setStorageBackend` mutations. The auth-token field is write-only: a blank field leaves the stored token untouched, and a "Clear token" action removes it. The destructive blob-migration flow is unchanged.
|
|
11
|
+
|
|
12
|
+
No schema or RPC contract changes.
|
|
13
|
+
|
|
14
|
+
- 270ef29: Declutter the Script Packages settings page with progressive disclosure.
|
|
15
|
+
|
|
16
|
+
The page stacked five always-open cards. The common case now stays prominent - install state plus the allowed-packages allowlist - and the advanced configuration (registry & storage summary, storage backend, satellite sync) moves into a collapsed-by-default accordion. The destructive storage-migration trigger is now guarded by a confirmation modal so it is never a single stray click.
|
|
17
|
+
|
|
18
|
+
- 270ef29: Add garbage collection for script packages, reclaiming both shared blob storage and per-host disk.
|
|
19
|
+
|
|
20
|
+
Two things accumulated and were never reclaimed; both are now collected with provably-safe guards (referenced-set + grace + lock / active-run protection - when in doubt, retain).
|
|
21
|
+
|
|
22
|
+
- **Blob GC (Postgres / S3 reclamation).** A new `gcBlobs` RPC (manage-gated) plus a daily scheduled job prune content-addressed blobs no longer referenced by any retained lockfile manifest (the current desired hash plus the previous N, default 1, for rollback / in-flight reconciles). Candidates are only deleted after a grace window (default 24h, keyed on `created_at`), each blob's bytes are removed from its recorded backend before its index row, and the pass holds the installer-election advisory lock so it is mutually exclusive with installs and storage migrations. The settings UI surfaces last-run and total-reclaimed figures and a "Run cleanup now" action. The installer now records each successful manifest in a new `script_package_lockfile_history` table so the retained set is computable; GC state lives in `script_package_blob_gc_state`.
|
|
23
|
+
- **Tree GC (per-host disk).** After a successful symlink flip, each host (core pod or satellite) sweeps its `trees/<lockfileHash>/` dirs, deleting non-current trees older than a grace window (default 1h). `current`'s target is never deleted. Active-run safety uses a conservative mtime-keyed grace window chosen to exceed the longest run timeout, since runs are throwaway subprocesses with no robust cross-process refcount - a tree only becomes eligible once no live run could still be pinned to it.
|
|
24
|
+
|
|
25
|
+
Adds the `gcBlobs` / `getBlobGcState` RPCs, the `BlobGcSummary` / `BlobGcState` schemas, and two new Drizzle tables (`script_package_lockfile_history`, `script_package_blob_gc_state`).
|
|
26
|
+
|
|
27
|
+
- b995afb: Autocomplete the import specifier itself in script editors.
|
|
28
|
+
|
|
29
|
+
Lazy type acquisition only loads a package's types once its name is already in the buffer, so while you were still typing the import specifier (`import {} from "lod"`) there were no suggestions - the lazy-ATA catch-22. Script editors now suggest installed package names directly in import-specifier position; selecting one (e.g. `lodash`) inserts the name, and the existing ATA loop then loads its `@types/lodash` closure so members complete.
|
|
30
|
+
|
|
31
|
+
- `@checkstack/ui`: `CodeEditor`/`TypefoxEditor` gained an injected `importablePackages?: string[]` prop and a dedicated Monaco completion provider (registered once per `typescript`/`javascript` language, scoped to the editor's model, disposed on unmount). It fires ONLY when the cursor is inside an import/require module-specifier string - detected by a new pure, unit-tested helper `importSpecifierCompletionContext(lineUpToCursor)` that handles `from "…"`, bare `import "…"`, `require("…")`, and dynamic `import("…")`, returns the partial specifier + the replace range, and returns null once the string is closed or outside an import. Items are `kind: Module`, insert the bare name without touching the quotes, and coexist with (do not replace) the TS worker's own completions. Trigger characters: `"`, `'`, and `/` (for scoped subpaths); manual invoke (Ctrl+Space) also works. A new pure helper `importablePackageNames` filters a raw manifest name list (excludes `@types/*`, dedupes, sorts).
|
|
32
|
+
- `@checkstack/script-packages-frontend`: `useScriptPackageTypeAcquisition()` now also returns `importablePackages`, derived from the installed manifest (what is actually resolvable at runtime) with `@types/*` companions excluded - you import `lodash`, never `@types/lodash` (the `@types` package still backs the closure types).
|
|
33
|
+
- `@checkstack/automation-frontend` / `@checkstack/healthcheck-frontend`: pass `importablePackages` into `DynamicForm` alongside the existing `acquireTypes` wiring, so both the Run Script action editor and healthcheck collector editors get import-name completion.
|
|
34
|
+
|
|
35
|
+
The completion list is plugin-agnostic in `@checkstack/ui` (the names are injected); it never fires outside import-string positions, so normal completions are unaffected.
|
|
36
|
+
|
|
37
|
+
- b995afb: Fix package IntelliSense in script editors: lazy Automatic Type Acquisition (ATA) with proper `@types/*` resolution.
|
|
38
|
+
|
|
39
|
+
Script editors (automation "Run Script (TypeScript)" and healthcheck collectors) now provide real autocomplete for installed npm packages. Importing a package whose types live in DefinitelyTyped - e.g. `import { debounce } from "lodash"` (lodash ships no own types; `@types/lodash` does) - now yields member completions. Previously no package completions appeared at all.
|
|
40
|
+
|
|
41
|
+
Root cause: the old rollup wrapped each package's raw, multi-file `.d.ts` (with `export =`, `export as namespace`, and triple-slash `/// <reference path>` chains) inside a single `declare module "<name>" { ... }`, which the TypeScript worker silently rejected, and it truncated large type sets (lodash is ~866 KB across ~700 files) at a 256 KB cap.
|
|
42
|
+
|
|
43
|
+
The fix registers the REAL declaration files at their `node_modules/...` virtual paths and lets TypeScript's own NodeJs + `@types` resolution do the work:
|
|
44
|
+
|
|
45
|
+
- `@checkstack/script-packages-backend`: replaced `rollupPackageTypes` with a tree-driven closure extractor (`resolvePackageTypeClosure`). Given a bare specifier, it resolves against the materialized tree - own types via `package.json` `types`/`typings`/`exports` (bundled-types packages like `zod`/`dayjs`), the `@types/<mangled>` companion when it exists (`lodash` -> `@types/lodash`, scoped `@babel/core` -> `@types/babel__core`), or both, or neither (graceful empty, never a throw). It follows `/// <reference path|types>` and relative imports, includes each package's `package.json`, leaves every file UNWRAPPED, and surfaces a `truncated` flag instead of silently capping. Served from a new raw, HTTP-cacheable route `GET /api/script-packages/types/:lockfileHash/:specifier` (`Cache-Control: private, max-age=1y, immutable`), auth-gated by `script-packages.read`.
|
|
46
|
+
- `@checkstack/script-packages-common`: **BREAKING** - replaced the `listPackageTypes` RPC procedure and `PackageTypesSchema { name, version, dts }` with `PackageTypeClosureSchema` (a `{ path, content }` file-map plus `hasOwnTypes`/`hasAtTypes`/`notFound`/`truncated`) served over the cacheable HTTP route. Added a shared `buildTypeAcquisitionPath`/`parseTypeAcquisitionPath` path contract.
|
|
47
|
+
- `@checkstack/ui`: `CodeEditor`/`TypefoxEditor` gained an injected `acquireTypes` resolver + `acquireResetKey`. On debounced buffer change it parses bare `import`/`require` specifiers (pure, unit-tested) and lazily fetches + registers each NEW package's closure via `addExtraLib` at `file:///node_modules/...`, deduped by a shared acquired-set that resets when the install hash changes. Compiler options set `moduleResolution: NodeJs`, `baseUrl: "file:///"`, and `typeRoots` so a bare import resolves to its `@types` companion. The `context` ambient global keeps working unchanged.
|
|
48
|
+
- `@checkstack/script-packages-frontend`: replaced the old `useScriptPackageTypes` (which concatenated the broken `dts`) with `useScriptPackageTypeAcquisition()`, returning the `acquireTypes` resolver (targets the cacheable route, zod-validates the response) and the current `lockfileHash` as `acquireResetKey`.
|
|
49
|
+
- `@checkstack/automation-frontend` / `@checkstack/healthcheck-frontend`: wired the resolver into the Run Script and collector editors.
|
|
50
|
+
|
|
51
|
+
State & scale: the type closure is derived on read from the materialized package tree (no new durable state). The editor's acquired-set is pod-local UI bookkeeping; the route is keyed by the cluster-wide `lockfileHash`, so the browser HTTP cache is correct across pods and only refetches after a new install changes the hash.
|
|
52
|
+
|
|
53
|
+
- b995afb: Add live, backend-proxied npm package-name autocomplete and version lookup to the Script Packages "Allowed packages" form.
|
|
54
|
+
|
|
55
|
+
The package-name field now searches the configured registry as you type (debounced). The version field suggests the package's published versions newest-first, defaulting to the registry's `latest` dist-tag while staying free-typeable for an exact manual pin. Picking a package now auto-fills its version (from the search hit, then upgraded to `latest`) instead of clearing the field, and the version dropdown stays open so a version is actually selectable.
|
|
56
|
+
|
|
57
|
+
This is fully backend-proxied so it reuses the SAME configured registry + auth the install path uses: the registry can be private with a server-side-only auth token (the client only ever sees `hasAuthToken`), and browsers can't reach arbitrary registries due to CORS.
|
|
58
|
+
|
|
59
|
+
- `@checkstack/script-packages-common`: two new `manage`-gated query procedures - `searchPackages` (input `{ text }`, output `{ items: [{ name, version?, description? }] }`) and `getPackageVersions` (input `{ name }`, output `{ versions, distTags? }`). Output `version`/`versions` are relaxed to plain strings so valid-but-unusual registry versions surface as suggestions; strict `PackageVersionSchema` validation still applies on `addPackage`.
|
|
60
|
+
- `@checkstack/script-packages-backend`: new `registry-client` (fetch-based, AbortController timeout, size cap, zod-validated registry responses, scoped-registry selection, tolerant semver-descending sort, a best-effort pod-local read cache that is never a source of truth, and errors that never leak the auth token). Search results use the registry's own relevance ranking from `-/v1/search`. The registry + token resolution used by the install path is factored into a shared `resolveRegistryRequestConfig` helper reused by both the new RPC handlers and the installer.
|
|
61
|
+
- `@checkstack/script-packages-frontend`: the package and version inputs become live comboboxes (Popover + Input) with inline pinned-version validation before "Add" is enabled. Selecting a suggestion routes through a dedicated `onSelect(hit)` callback (separate from manual-typing `onValueChange`) so a pick auto-fills the version instead of clearing it; the popover dismissable layer ignores interactions originating on the anchor input, fixing the version dropdown that previously opened then immediately closed. The version-autofill decision logic is extracted into a pure, unit-tested helper.
|
|
62
|
+
|
|
63
|
+
State & scale: the registry-client TTL cache is an explicitly non-authoritative, pod-local best-effort read cache (search/version lookups are non-authoritative reads); a cache miss on another pod simply re-fetches, so pod-local divergence is harmless. No new durable state of record is introduced.
|
|
64
|
+
|
|
65
|
+
- 270ef29: Wire up the script-packages RPC router, admin UI, and editor IntelliSense.
|
|
66
|
+
|
|
67
|
+
- `script-packages-backend`: the oRPC router implementing the full
|
|
68
|
+
contract (allowlist CRUD, registry config with encrypted write-only auth
|
|
69
|
+
token, `installNow` via the elected installer, size cap, storage backend
|
|
70
|
+
selection, install state, `getManifest` / `downloadBlob` for reconcilers,
|
|
71
|
+
and `listPackageTypes`), the `installNow` controller (election, size-cap
|
|
72
|
+
enforcement, `script-packages.changed` emit, blocked during migration),
|
|
73
|
+
the `.d.ts` rollup, the singleton config stores, and the full plugin
|
|
74
|
+
wiring (broadcast-hook reconcile + startup backstop).
|
|
75
|
+
- `script-packages-common`: admin route for the settings page.
|
|
76
|
+
- `script-packages-frontend`: the Settings -> Script Packages admin page
|
|
77
|
+
(allowlist, install state + size, registry/storage summary, satellite
|
|
78
|
+
sync) and the `useScriptPackageTypes()` hook.
|
|
79
|
+
- `automation-frontend` / `healthcheck-frontend`: merge installed-package
|
|
80
|
+
`.d.ts` into the script-editor `typeDefinitions` so `import` from an
|
|
81
|
+
allowlisted package autocompletes in every script field.
|
|
82
|
+
|
|
83
|
+
- 270ef29: Add storage-backend migration for script packages.
|
|
84
|
+
|
|
85
|
+
- `migrateStorage({ target })` copies every blob from the active backend to
|
|
86
|
+
the target, verifies each copy byte-for-byte (read back + SHA-256 compare),
|
|
87
|
+
flips the per-blob `backend` only after a verified copy, then atomically
|
|
88
|
+
switches the active backend. Resumable from a partial state (the work set
|
|
89
|
+
is re-derived from the index), aborts cleanly on an integrity mismatch
|
|
90
|
+
(active backend untouched), and supports optional source GC. Built on the
|
|
91
|
+
Phase 2 dual-backend read fallback, so reads keep working mid-migration.
|
|
92
|
+
- Migration and `installNow` are mutually exclusive via the installer
|
|
93
|
+
advisory lock; `setStorageBackend` is refused while a migration runs.
|
|
94
|
+
- New `listStorageBackends` RPC + admin UI: a storage-backend card with a
|
|
95
|
+
target selector, "Migrate" action, and live progress / completion / error
|
|
96
|
+
state.
|
|
97
|
+
|
|
98
|
+
### Patch Changes
|
|
99
|
+
|
|
100
|
+
- b995afb: Improve the automation Run Script secret → env mapping editor and script IntelliSense.
|
|
101
|
+
|
|
102
|
+
- **Searchable secret picker with existence validation.** The secret → env mapping editor (`SecretEnvEditor`) now uses a searchable, keyboard-navigable combobox (modeled on `VariablePicker` / `PackageNameCombobox`, `isLowPower`-aware) populated from the secrets plugin's `listSecretNames`, replacing the plain `<input>` + `<datalist>`. A free-typed name still round-trips (a secret may be created later). When a row references a name that the loaded list does not contain, the row shows a non-blocking warning (red border + message); save is not prevented. The existence check lives in a pure, unit-tested `unknownSecretNames` helper.
|
|
103
|
+
- **Clearer field description.** The `secretEnv` field descriptions on the `run_script` / `run_shell` actions no longer show the stored `${{ secrets.NAME }}` template (which is confusing in a UI that takes a bare name); they now describe the actual UI behavior and how the value is injected (`process.env.<ENV_NAME>` / `$<ENV_NAME>`) and masked.
|
|
104
|
+
- **`process.env.<ENV_NAME>` autocomplete.** Declared `secretEnv` env-var names now autocomplete under `process.env.` in the Run Script (TypeScript) Monaco editor and are typed `string`, via an ambient `NodeJS.ProcessEnv` augmentation merged into the editor type definitions. New pure, unit-tested generators `generateSecretEnvTypes` and `secretEnvEnvNames` (exported from `@checkstack/automation-frontend`) drive this; the augmentation coexists with `@types/node`'s existing index signature.
|
|
105
|
+
- **Shared combobox-interaction helper.** The "opens-then-immediately-closes" popover guard (`comboboxAnchorProps` / `isAnchorInteraction`) is promoted from `@checkstack/script-packages-frontend` into `@checkstack/ui` so the new secret picker and the existing package/version comboboxes share one implementation; the package comboboxes now import it from `@checkstack/ui` and the local copy is removed.
|
|
106
|
+
|
|
107
|
+
- b995afb: Tidy the user menu: move "Script packages" and "Secrets" into the **Configuration** group (the now-empty **Administration** group is gone), and display the user-menu groups in alphabetical order instead of a hardcoded canonical order.
|
|
108
|
+
- Updated dependencies [b995afb]
|
|
109
|
+
- Updated dependencies [270ef29]
|
|
110
|
+
- Updated dependencies [b995afb]
|
|
111
|
+
- Updated dependencies [b995afb]
|
|
112
|
+
- Updated dependencies [b995afb]
|
|
113
|
+
- Updated dependencies [270ef29]
|
|
114
|
+
- Updated dependencies [270ef29]
|
|
115
|
+
- Updated dependencies [b995afb]
|
|
116
|
+
- Updated dependencies [270ef29]
|
|
117
|
+
- Updated dependencies [270ef29]
|
|
118
|
+
- Updated dependencies [270ef29]
|
|
119
|
+
- Updated dependencies [b995afb]
|
|
120
|
+
- Updated dependencies [b995afb]
|
|
121
|
+
- Updated dependencies [b995afb]
|
|
122
|
+
- Updated dependencies [270ef29]
|
|
123
|
+
- Updated dependencies [270ef29]
|
|
124
|
+
- Updated dependencies [b995afb]
|
|
125
|
+
- Updated dependencies [270ef29]
|
|
126
|
+
- Updated dependencies [b995afb]
|
|
127
|
+
- Updated dependencies [270ef29]
|
|
128
|
+
- @checkstack/ui@1.12.0
|
|
129
|
+
- @checkstack/script-packages-common@0.2.0
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@checkstack/script-packages-frontend",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"license": "Elastic-2.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"checkstack": {
|
|
8
|
+
"type": "frontend"
|
|
9
|
+
},
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"import": "./src/index.tsx"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"scripts": {
|
|
17
|
+
"build": "tsc",
|
|
18
|
+
"dev": "tsc --watch",
|
|
19
|
+
"typecheck": "tsgo -b",
|
|
20
|
+
"lint": "bun run lint:code",
|
|
21
|
+
"lint:code": "eslint . --max-warnings 0",
|
|
22
|
+
"test": "bun test"
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"@checkstack/common": "0.12.0",
|
|
26
|
+
"@checkstack/frontend-api": "0.6.0",
|
|
27
|
+
"@checkstack/script-packages-common": "0.1.0",
|
|
28
|
+
"@checkstack/ui": "1.11.0",
|
|
29
|
+
"lucide-react": "0.562.0",
|
|
30
|
+
"react": "^18.3.1",
|
|
31
|
+
"react-router-dom": "^6.20.0"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@types/react": "^18.3.18",
|
|
35
|
+
"typescript": "^5.7.2",
|
|
36
|
+
"@checkstack/tsconfig": "0.0.7",
|
|
37
|
+
"@checkstack/scripts": "0.3.4"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Search } from "lucide-react";
|
|
3
|
+
import { usePluginClient } from "@checkstack/frontend-api";
|
|
4
|
+
import {
|
|
5
|
+
ScriptPackagesApi,
|
|
6
|
+
type PackageSearchHit,
|
|
7
|
+
} from "@checkstack/script-packages-common";
|
|
8
|
+
import {
|
|
9
|
+
Input,
|
|
10
|
+
Popover,
|
|
11
|
+
PopoverAnchor,
|
|
12
|
+
PopoverContent,
|
|
13
|
+
comboboxAnchorProps,
|
|
14
|
+
isAnchorInteraction,
|
|
15
|
+
} from "@checkstack/ui";
|
|
16
|
+
import { useDebouncedValue } from "../hooks/useDebouncedValue";
|
|
17
|
+
|
|
18
|
+
export interface PackageNameComboboxProps {
|
|
19
|
+
/** Current package-name value (controlled). */
|
|
20
|
+
value: string;
|
|
21
|
+
/**
|
|
22
|
+
* Called ONLY when the user manually edits the name (typing). Selecting a
|
|
23
|
+
* suggestion does NOT fire this - it fires `onSelect` instead, so the
|
|
24
|
+
* parent can distinguish "user typed a new name" (reset version) from
|
|
25
|
+
* "user picked a suggestion" (auto-fill version).
|
|
26
|
+
*/
|
|
27
|
+
onValueChange: (next: string) => void;
|
|
28
|
+
/** Called when a suggestion is picked, with the full search hit. */
|
|
29
|
+
onSelect?: (hit: PackageSearchHit) => void;
|
|
30
|
+
id?: string;
|
|
31
|
+
placeholder?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Package-name field with live, backend-proxied npm search. Composes
|
|
36
|
+
* `Popover` + `Input` (no Combobox primitive exists) and mirrors
|
|
37
|
+
* `VariablePicker`'s structure: a scrollable result list with a Search icon.
|
|
38
|
+
*
|
|
39
|
+
* Search is debounced ~300ms and only runs at >= 2 chars. Selecting a hit
|
|
40
|
+
* sets the value and closes the list. The query is the script-packages
|
|
41
|
+
* plugin's own `searchPackages`, so its `useQuery` is same-plugin (no manual
|
|
42
|
+
* invalidation needed).
|
|
43
|
+
*/
|
|
44
|
+
export const PackageNameCombobox: React.FC<PackageNameComboboxProps> = ({
|
|
45
|
+
value,
|
|
46
|
+
onValueChange,
|
|
47
|
+
onSelect,
|
|
48
|
+
id,
|
|
49
|
+
placeholder,
|
|
50
|
+
}) => {
|
|
51
|
+
const client = usePluginClient(ScriptPackagesApi);
|
|
52
|
+
const [open, setOpen] = React.useState(false);
|
|
53
|
+
const inputRef = React.useRef<HTMLInputElement>(null);
|
|
54
|
+
|
|
55
|
+
const debouncedName = useDebouncedValue(value, 300);
|
|
56
|
+
const searchQuery = client.searchPackages.useQuery(
|
|
57
|
+
{ text: debouncedName },
|
|
58
|
+
{ enabled: debouncedName.trim().length >= 2 },
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
const items = searchQuery.data?.items ?? [];
|
|
62
|
+
const canSearch = value.trim().length >= 2;
|
|
63
|
+
|
|
64
|
+
const handleChange = (next: string) => {
|
|
65
|
+
onValueChange(next);
|
|
66
|
+
setOpen(next.trim().length >= 2);
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const handlePick = (hit: PackageSearchHit) => {
|
|
70
|
+
onSelect?.(hit);
|
|
71
|
+
setOpen(false);
|
|
72
|
+
// Keep focus in the name input; we don't want it to bounce to the version
|
|
73
|
+
// field automatically (the user moves there deliberately).
|
|
74
|
+
inputRef.current?.focus();
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<Popover open={open && canSearch} onOpenChange={setOpen}>
|
|
79
|
+
<PopoverAnchor asChild>
|
|
80
|
+
<Input
|
|
81
|
+
ref={inputRef}
|
|
82
|
+
id={id}
|
|
83
|
+
value={value}
|
|
84
|
+
onChange={(e) => handleChange(e.target.value)}
|
|
85
|
+
onFocus={() => {
|
|
86
|
+
if (canSearch) setOpen(true);
|
|
87
|
+
}}
|
|
88
|
+
placeholder={placeholder}
|
|
89
|
+
autoComplete="off"
|
|
90
|
+
{...comboboxAnchorProps}
|
|
91
|
+
/>
|
|
92
|
+
</PopoverAnchor>
|
|
93
|
+
<PopoverContent
|
|
94
|
+
align="start"
|
|
95
|
+
className="w-[--radix-popover-trigger-width] p-0"
|
|
96
|
+
// Keep focus in the input so typing continues uninterrupted, and don't
|
|
97
|
+
// yank focus on close (would re-trigger the anchor's onFocus).
|
|
98
|
+
onOpenAutoFocus={(e) => e.preventDefault()}
|
|
99
|
+
onCloseAutoFocus={(e) => e.preventDefault()}
|
|
100
|
+
// Clicking/focusing the anchor input must NOT dismiss the list - that
|
|
101
|
+
// is what produced the "opens then immediately closes" flicker: Radix
|
|
102
|
+
// saw the focusing click on the anchor as an outside interaction.
|
|
103
|
+
onPointerDownOutside={(e) => {
|
|
104
|
+
if (isAnchorInteraction(e.target)) e.preventDefault();
|
|
105
|
+
}}
|
|
106
|
+
onFocusOutside={(e) => {
|
|
107
|
+
if (isAnchorInteraction(e.target)) e.preventDefault();
|
|
108
|
+
}}
|
|
109
|
+
>
|
|
110
|
+
<div className="max-h-80 overflow-y-auto py-1">
|
|
111
|
+
{searchQuery.isFetching && items.length === 0 ? (
|
|
112
|
+
<p className="px-3 py-4 text-center text-xs text-muted-foreground italic">
|
|
113
|
+
Searching…
|
|
114
|
+
</p>
|
|
115
|
+
) : items.length === 0 ? (
|
|
116
|
+
<p className="px-3 py-4 text-center text-xs text-muted-foreground italic">
|
|
117
|
+
No matching packages.
|
|
118
|
+
</p>
|
|
119
|
+
) : (
|
|
120
|
+
items.map((item) => (
|
|
121
|
+
<button
|
|
122
|
+
key={item.name}
|
|
123
|
+
type="button"
|
|
124
|
+
// Use mousedown so the pick lands before the input's blur/focus
|
|
125
|
+
// shuffle, and prevent default so the input keeps focus.
|
|
126
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
127
|
+
onClick={() => handlePick(item)}
|
|
128
|
+
className="flex w-full flex-col items-start gap-0.5 px-3 py-1.5 text-left hover:bg-accent hover:text-accent-foreground"
|
|
129
|
+
>
|
|
130
|
+
<span className="flex w-full items-center gap-2">
|
|
131
|
+
<Search className="h-3 w-3 shrink-0 text-muted-foreground" />
|
|
132
|
+
<code className="flex-1 truncate font-mono text-xs">
|
|
133
|
+
{item.name}
|
|
134
|
+
</code>
|
|
135
|
+
{item.version && (
|
|
136
|
+
<span className="shrink-0 font-mono text-[10px] text-muted-foreground">
|
|
137
|
+
{item.version}
|
|
138
|
+
</span>
|
|
139
|
+
)}
|
|
140
|
+
</span>
|
|
141
|
+
{item.description && (
|
|
142
|
+
<span className="line-clamp-1 pl-5 text-[10px] text-muted-foreground">
|
|
143
|
+
{item.description}
|
|
144
|
+
</span>
|
|
145
|
+
)}
|
|
146
|
+
</button>
|
|
147
|
+
))
|
|
148
|
+
)}
|
|
149
|
+
</div>
|
|
150
|
+
</PopoverContent>
|
|
151
|
+
</Popover>
|
|
152
|
+
);
|
|
153
|
+
};
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Check, Tag } from "lucide-react";
|
|
3
|
+
import { usePluginClient } from "@checkstack/frontend-api";
|
|
4
|
+
import { ScriptPackagesApi } from "@checkstack/script-packages-common";
|
|
5
|
+
import {
|
|
6
|
+
Input,
|
|
7
|
+
Popover,
|
|
8
|
+
PopoverAnchor,
|
|
9
|
+
PopoverContent,
|
|
10
|
+
cn,
|
|
11
|
+
comboboxAnchorProps,
|
|
12
|
+
isAnchorInteraction,
|
|
13
|
+
} from "@checkstack/ui";
|
|
14
|
+
|
|
15
|
+
export interface PackageVersionComboboxProps {
|
|
16
|
+
/** The chosen package name; versions are fetched for it (when non-empty). */
|
|
17
|
+
packageName: string;
|
|
18
|
+
/** Current version value (controlled, free-typeable). */
|
|
19
|
+
value: string;
|
|
20
|
+
onValueChange: (next: string) => void;
|
|
21
|
+
/**
|
|
22
|
+
* Called once with `dist-tags.latest` (if any) the first time versions
|
|
23
|
+
* load for a package, so the parent can default-select it. The parent
|
|
24
|
+
* decides whether to honor it (e.g. only when the field is still empty).
|
|
25
|
+
*/
|
|
26
|
+
onVersionsLoaded?: (input: { latest?: string }) => void;
|
|
27
|
+
id?: string;
|
|
28
|
+
placeholder?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Hybrid version field: fetched, newest-first version suggestions from the
|
|
33
|
+
* configured registry PLUS a free-typeable input so an exact pin can be
|
|
34
|
+
* entered by hand. Backend-proxied via the script-packages plugin's
|
|
35
|
+
* `getPackageVersions` (same-plugin query, no manual invalidation).
|
|
36
|
+
*
|
|
37
|
+
* Validation of the typed value against the strict pinned-version rules is
|
|
38
|
+
* the parent's job (surfaced inline next to the Add button); this component
|
|
39
|
+
* only sources suggestions and lets the value be edited freely.
|
|
40
|
+
*/
|
|
41
|
+
export const PackageVersionCombobox: React.FC<PackageVersionComboboxProps> = ({
|
|
42
|
+
packageName,
|
|
43
|
+
value,
|
|
44
|
+
onValueChange,
|
|
45
|
+
onVersionsLoaded,
|
|
46
|
+
id,
|
|
47
|
+
placeholder,
|
|
48
|
+
}) => {
|
|
49
|
+
const client = usePluginClient(ScriptPackagesApi);
|
|
50
|
+
const [open, setOpen] = React.useState(false);
|
|
51
|
+
|
|
52
|
+
const versionsQuery = client.getPackageVersions.useQuery(
|
|
53
|
+
{ name: packageName },
|
|
54
|
+
{ enabled: packageName.trim().length > 0 },
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
const versions = React.useMemo(
|
|
58
|
+
() => versionsQuery.data?.versions ?? [],
|
|
59
|
+
[versionsQuery.data],
|
|
60
|
+
);
|
|
61
|
+
const distTags = versionsQuery.data?.distTags;
|
|
62
|
+
const latest = distTags?.latest;
|
|
63
|
+
|
|
64
|
+
// Fire `onVersionsLoaded` once per package once its versions resolve, so the
|
|
65
|
+
// parent can default-select `latest`. Keyed on the package name + a load
|
|
66
|
+
// flag so re-renders don't re-fire it.
|
|
67
|
+
const notifiedForRef = React.useRef<string | null>(null);
|
|
68
|
+
React.useEffect(() => {
|
|
69
|
+
if (!versionsQuery.data) return;
|
|
70
|
+
if (notifiedForRef.current === packageName) return;
|
|
71
|
+
notifiedForRef.current = packageName;
|
|
72
|
+
onVersionsLoaded?.({ latest });
|
|
73
|
+
}, [versionsQuery.data, packageName, latest, onVersionsLoaded]);
|
|
74
|
+
|
|
75
|
+
/** Build the tag label(s) for a version (e.g. "latest", "next"). */
|
|
76
|
+
const tagsFor = (version: string): string[] =>
|
|
77
|
+
distTags
|
|
78
|
+
? Object.entries(distTags)
|
|
79
|
+
.filter(([, v]) => v === version)
|
|
80
|
+
.map(([tag]) => tag)
|
|
81
|
+
: [];
|
|
82
|
+
|
|
83
|
+
const inputRef = React.useRef<HTMLInputElement>(null);
|
|
84
|
+
|
|
85
|
+
const handlePick = (version: string) => {
|
|
86
|
+
onValueChange(version);
|
|
87
|
+
setOpen(false);
|
|
88
|
+
inputRef.current?.focus();
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const hasSuggestions = versions.length > 0;
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<Popover open={open && hasSuggestions} onOpenChange={setOpen}>
|
|
95
|
+
<PopoverAnchor asChild>
|
|
96
|
+
<Input
|
|
97
|
+
ref={inputRef}
|
|
98
|
+
id={id}
|
|
99
|
+
value={value}
|
|
100
|
+
onChange={(e) => onValueChange(e.target.value)}
|
|
101
|
+
onFocus={() => {
|
|
102
|
+
if (hasSuggestions) setOpen(true);
|
|
103
|
+
}}
|
|
104
|
+
onClick={() => {
|
|
105
|
+
if (hasSuggestions) setOpen(true);
|
|
106
|
+
}}
|
|
107
|
+
placeholder={placeholder}
|
|
108
|
+
autoComplete="off"
|
|
109
|
+
{...comboboxAnchorProps}
|
|
110
|
+
/>
|
|
111
|
+
</PopoverAnchor>
|
|
112
|
+
{hasSuggestions && (
|
|
113
|
+
<PopoverContent
|
|
114
|
+
align="start"
|
|
115
|
+
className="w-[--radix-popover-trigger-width] p-0"
|
|
116
|
+
onOpenAutoFocus={(e) => e.preventDefault()}
|
|
117
|
+
onCloseAutoFocus={(e) => e.preventDefault()}
|
|
118
|
+
onPointerDownOutside={(e) => {
|
|
119
|
+
if (isAnchorInteraction(e.target)) e.preventDefault();
|
|
120
|
+
}}
|
|
121
|
+
onFocusOutside={(e) => {
|
|
122
|
+
if (isAnchorInteraction(e.target)) e.preventDefault();
|
|
123
|
+
}}
|
|
124
|
+
>
|
|
125
|
+
<div className="max-h-80 overflow-y-auto py-1">
|
|
126
|
+
{versions.map((version) => {
|
|
127
|
+
const tags = tagsFor(version);
|
|
128
|
+
const selected = version === value;
|
|
129
|
+
return (
|
|
130
|
+
<button
|
|
131
|
+
key={version}
|
|
132
|
+
type="button"
|
|
133
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
134
|
+
onClick={() => handlePick(version)}
|
|
135
|
+
className={cn(
|
|
136
|
+
"flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent hover:text-accent-foreground",
|
|
137
|
+
selected && "bg-accent/50",
|
|
138
|
+
)}
|
|
139
|
+
>
|
|
140
|
+
<Check
|
|
141
|
+
className={cn(
|
|
142
|
+
"h-3 w-3 shrink-0",
|
|
143
|
+
selected ? "opacity-100" : "opacity-0",
|
|
144
|
+
)}
|
|
145
|
+
/>
|
|
146
|
+
<code className="flex-1 truncate font-mono text-xs">
|
|
147
|
+
{version}
|
|
148
|
+
</code>
|
|
149
|
+
{tags.map((tag) => (
|
|
150
|
+
<span
|
|
151
|
+
key={tag}
|
|
152
|
+
className="flex shrink-0 items-center gap-0.5 rounded bg-muted px-1 text-[10px] text-muted-foreground"
|
|
153
|
+
>
|
|
154
|
+
<Tag className="h-2.5 w-2.5" />
|
|
155
|
+
{tag}
|
|
156
|
+
</span>
|
|
157
|
+
))}
|
|
158
|
+
</button>
|
|
159
|
+
);
|
|
160
|
+
})}
|
|
161
|
+
</div>
|
|
162
|
+
</PopoverContent>
|
|
163
|
+
)}
|
|
164
|
+
</Popover>
|
|
165
|
+
);
|
|
166
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Link } from "react-router-dom";
|
|
3
|
+
import { Package } from "lucide-react";
|
|
4
|
+
import type { UserMenuItemsContext } from "@checkstack/frontend-api";
|
|
5
|
+
import { DropdownMenuItem } from "@checkstack/ui";
|
|
6
|
+
import { resolveRoute } from "@checkstack/common";
|
|
7
|
+
import {
|
|
8
|
+
scriptPackagesRoutes,
|
|
9
|
+
scriptPackagesAccess,
|
|
10
|
+
pluginMetadata,
|
|
11
|
+
} from "@checkstack/script-packages-common";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* "Script Packages" entry in the user/settings menu. Gated on
|
|
15
|
+
* `script-packages.manage` (the page itself is also access-gated by the
|
|
16
|
+
* route); hiding the link is the cleaner UX for unauthorised users.
|
|
17
|
+
*/
|
|
18
|
+
export const ScriptPackagesMenuItems = ({
|
|
19
|
+
accessRules: userPerms,
|
|
20
|
+
}: UserMenuItemsContext) => {
|
|
21
|
+
const qualifiedId = `${pluginMetadata.pluginId}.${scriptPackagesAccess.manage.id}`;
|
|
22
|
+
const canManage = userPerms.includes("*") || userPerms.includes(qualifiedId);
|
|
23
|
+
|
|
24
|
+
if (!canManage) {
|
|
25
|
+
return <React.Fragment />;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<Link to={resolveRoute(scriptPackagesRoutes.routes.settings)}>
|
|
30
|
+
<DropdownMenuItem icon={<Package className="w-4 h-4" />}>
|
|
31
|
+
Script Packages
|
|
32
|
+
</DropdownMenuItem>
|
|
33
|
+
</Link>
|
|
34
|
+
);
|
|
35
|
+
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { applyLatestDistTag, versionFromHit } from "./version-autofill";
|
|
3
|
+
|
|
4
|
+
describe("versionFromHit", () => {
|
|
5
|
+
test("uses the hit's version when present", () => {
|
|
6
|
+
expect(versionFromHit({ version: "4.17.21" })).toBe("4.17.21");
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test("falls back to empty when the hit has no version", () => {
|
|
10
|
+
expect(versionFromHit({})).toBe("");
|
|
11
|
+
});
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
describe("applyLatestDistTag", () => {
|
|
15
|
+
test("fills latest when the field is empty", () => {
|
|
16
|
+
expect(applyLatestDistTag({ current: "", latest: "5.0.0" })).toBe("5.0.0");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("fills latest when the field is whitespace-only", () => {
|
|
20
|
+
expect(applyLatestDistTag({ current: " ", latest: "5.0.0" })).toBe(
|
|
21
|
+
"5.0.0",
|
|
22
|
+
);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("does NOT clobber a value already seeded from the hit", () => {
|
|
26
|
+
expect(applyLatestDistTag({ current: "4.17.21", latest: "5.0.0" })).toBe(
|
|
27
|
+
"4.17.21",
|
|
28
|
+
);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("does NOT clobber a manually-typed pin", () => {
|
|
32
|
+
expect(applyLatestDistTag({ current: "1.2.3", latest: "5.0.0" })).toBe(
|
|
33
|
+
"1.2.3",
|
|
34
|
+
);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("keeps current unchanged when there is no latest tag", () => {
|
|
38
|
+
expect(applyLatestDistTag({ current: "", latest: undefined })).toBe("");
|
|
39
|
+
expect(applyLatestDistTag({ current: "1.0.0", latest: undefined })).toBe(
|
|
40
|
+
"1.0.0",
|
|
41
|
+
);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure version-field autofill logic for the "Allowed packages" form, extracted
|
|
3
|
+
* from the React wiring so the load-bearing decisions are unit-testable without
|
|
4
|
+
* a DOM/component harness.
|
|
5
|
+
*
|
|
6
|
+
* The flow has two distinct signals that must NOT clobber a user's manual pin:
|
|
7
|
+
*
|
|
8
|
+
* - The user picks a search suggestion → seed the version from the hit (its
|
|
9
|
+
* version is typically the latest published one).
|
|
10
|
+
* - The full version list later resolves with a `latest` dist-tag → upgrade
|
|
11
|
+
* the field to `latest`, but ONLY when it is still empty (so a value seeded
|
|
12
|
+
* from the hit, or a manually-typed pin, is preserved).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** Version chosen when a search suggestion is picked: the hit's version, else empty. */
|
|
16
|
+
export function versionFromHit(hit: { version?: string }): string {
|
|
17
|
+
return hit.version ?? "";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Next version value when the registry's version list resolves. Applies
|
|
22
|
+
* `latest` only when the current field is blank; otherwise keeps the current
|
|
23
|
+
* value untouched. Returns `current` unchanged when there is no `latest`.
|
|
24
|
+
*/
|
|
25
|
+
export function applyLatestDistTag({
|
|
26
|
+
current,
|
|
27
|
+
latest,
|
|
28
|
+
}: {
|
|
29
|
+
current: string;
|
|
30
|
+
latest?: string;
|
|
31
|
+
}): string {
|
|
32
|
+
if (latest && current.trim().length === 0) return latest;
|
|
33
|
+
return current;
|
|
34
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Return `value` debounced by `delayMs`: the returned value only updates
|
|
5
|
+
* after `value` has stopped changing for `delayMs`. Used to throttle the
|
|
6
|
+
* live package-search query so a fast typist doesn't fan out a request per
|
|
7
|
+
* keystroke.
|
|
8
|
+
*/
|
|
9
|
+
export function useDebouncedValue<T>(value: T, delayMs: number): T {
|
|
10
|
+
const [debounced, setDebounced] = React.useState(value);
|
|
11
|
+
|
|
12
|
+
React.useEffect(() => {
|
|
13
|
+
const timer = setTimeout(() => setDebounced(value), delayMs);
|
|
14
|
+
return () => clearTimeout(timer);
|
|
15
|
+
}, [value, delayMs]);
|
|
16
|
+
|
|
17
|
+
return debounced;
|
|
18
|
+
}
|