Skip to content

Commit 639a866

Browse files
committed
Merge remote-tracking branch 'origin/main' into feat/npmx-connector-allow-web-auth
2 parents 48311c2 + 8ee3608 commit 639a866

82 files changed

Lines changed: 6123 additions & 850 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/mirror-tangled.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ permissions:
1313
jobs:
1414
mirror:
1515
name: 🕸️ Mirror to Tangled
16+
if: ${{ github.repository == 'npmx-dev/npmx.dev' }}
1617
runs-on: ubuntu-24.04-arm
1718

1819
steps:

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ test-results/
3939

4040
# generated files
4141
shared/types/lexicons
42+
file-tree-sprite.svg
4243

4344
**/__screenshots__/**
4445

.oxfmtignore

Lines changed: 0 additions & 2 deletions
This file was deleted.

CONTRIBUTING.md

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ This focus helps guide our project decisions as a community and what we choose t
3333
- [Available commands](#available-commands)
3434
- [Project structure](#project-structure)
3535
- [Local connector CLI](#local-connector-cli)
36+
- [Mock connector (for local development)](#mock-connector-for-local-development)
3637
- [Code style](#code-style)
3738
- [TypeScript](#typescript)
3839
- [Server API patterns](#server-api-patterns)
@@ -104,6 +105,10 @@ pnpm dev # Start development server
104105
pnpm build # Production build
105106
pnpm preview # Preview production build
106107

108+
# Connector
109+
pnpm npmx-connector # Start the real connector (requires npm login)
110+
pnpm mock-connector # Start the mock connector (no npm login needed)
111+
107112
# Code Quality
108113
pnpm lint # Run linter (oxlint + oxfmt)
109114
pnpm lint:fix # Auto-fix lint issues
@@ -157,6 +162,36 @@ pnpm npmx-connector
157162

158163
The connector will check your npm authentication, generate a connection token, and listen for requests from npmx.dev.
159164

165+
### Mock connector (for local development)
166+
167+
If you're working on admin features (org management, package access controls, operations queue) and don't want to use your real npm account, you can run the mock connector instead:
168+
169+
```bash
170+
pnpm mock-connector
171+
```
172+
173+
This starts a mock connector server pre-populated with sample data (orgs, teams, members, packages). No npm login is required — operations succeed immediately without making real npm CLI calls.
174+
175+
The mock connector prints a connection URL to the terminal, just like the real connector. Click it (or paste the token manually) to connect the UI.
176+
177+
**Options:**
178+
179+
```bash
180+
pnpm mock-connector # default: port 31415, user "mock-user", sample data
181+
pnpm mock-connector --port 9999 # custom port
182+
pnpm mock-connector --user alice # custom username
183+
pnpm mock-connector --empty # start with no pre-populated data
184+
```
185+
186+
**Default sample data:**
187+
188+
- **@nuxt**: 4 members (mock-user, danielroe, pi0, antfu), 3 teams (core, docs, triage)
189+
- **@unjs**: 2 members (mock-user, pi0), 1 team (maintainers)
190+
- **Packages**: @nuxt/kit, @nuxt/schema, @unjs/nitro with team-based access controls
191+
192+
> [!TIP]
193+
> Run `pnpm dev` in a separate terminal to start the Nuxt dev server, then click the connection URL from the mock connector to connect.
194+
160195
## Code style
161196

162197
When committing changes, try to keep an eye out for unintended formatting updates. These can make a pull request look noisier than it really is and slow down the review process. Sometimes IDEs automatically reformat files on save, which can unintentionally introduce extra changes.
@@ -752,6 +787,74 @@ You need to either:
752787
1. Add a fixture file for that package/endpoint
753788
2. Update the mock handlers in `test/fixtures/mock-routes.cjs` (client) or `modules/runtime/server/cache.ts` (server)
754789

790+
### Testing connector features
791+
792+
Features that require authentication through the local connector (org management, package collaborators, operations queue) are tested using a mock connector server.
793+
794+
#### Architecture
795+
796+
The mock connector infrastructure is shared between the CLI, E2E tests, and Vitest component tests:
797+
798+
```
799+
cli/src/
800+
├── types.ts # ConnectorEndpoints contract (shared by real + mock)
801+
├── mock-state.ts # MockConnectorStateManager (canonical source)
802+
├── mock-app.ts # H3 mock app + MockConnectorServer class
803+
└── mock-server.ts # CLI entry point (pnpm mock-connector)
804+
805+
test/test-utils/ # Re-exports from cli/src/ for test convenience
806+
test/e2e/helpers/ # E2E-specific wrappers (fixtures, global setup)
807+
```
808+
809+
Both the real server (`cli/src/server.ts`) and the mock server (`cli/src/mock-app.ts`) conform to the `ConnectorEndpoints` interface defined in `cli/src/types.ts`. This ensures the API contract is enforced by TypeScript. When adding a new endpoint, update `ConnectorEndpoints` first, then implement it in both servers.
810+
811+
#### Vitest component tests (`test/nuxt/`)
812+
813+
- Mock the `useConnector` composable with reactive state
814+
- Use `document.body` queries for components using Teleport
815+
- See `test/nuxt/components/HeaderConnectorModal.spec.ts` for an example
816+
817+
```typescript
818+
// Create mock state
819+
const mockState = ref({ connected: false, npmUser: null, ... })
820+
821+
// Mock the composable
822+
vi.mock('~/composables/useConnector', () => ({
823+
useConnector: () => ({
824+
isConnected: computed(() => mockState.value.connected),
825+
// ... other properties
826+
}),
827+
}))
828+
```
829+
830+
#### Playwright E2E tests (`test/e2e/`)
831+
832+
- A mock HTTP server starts automatically via Playwright's global setup
833+
- Use the `mockConnector` fixture to set up test data and the `gotoConnected` helper to navigate with authentication
834+
835+
```typescript
836+
test('shows org members', async ({ page, gotoConnected, mockConnector }) => {
837+
// Set up test data
838+
await mockConnector.setOrgData('@testorg', {
839+
users: { testuser: 'owner', member1: 'admin' },
840+
})
841+
842+
// Navigate with connector authentication
843+
await gotoConnected('/@testorg')
844+
845+
// Test assertions
846+
await expect(page.getByRole('link', { name: '@testuser' })).toBeVisible()
847+
})
848+
```
849+
850+
The mock connector supports test endpoints for state manipulation:
851+
852+
- `/__test__/reset` - Reset all mock state
853+
- `/__test__/org` - Set org users, teams, and team members
854+
- `/__test__/user-orgs` - Set user's organizations
855+
- `/__test__/user-packages` - Set user's packages
856+
- `/__test__/package` - Set package collaborators
857+
755858
## Submitting changes
756859

757860
### Before submitting

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ npmx.dev supports npm permalinks – just replace `npmjs.com` with `npmx.dev
116116
| `npmjs.com/org/nuxt` | [`npmx.dev/org/nuxt`](https://npmx.dev/org/nuxt) |
117117

118118
> [!TIP]
119-
> Want automatic redirects? Try the [npmx-replace browser extension](https://github.com/tylersayshi/npmx-replace-extension) (Chrome only for now).
119+
> Want automatic redirects? Try the [npmx-replace browser extension](https://github.com/tylersayshi/npmx-replace-extension) (Chrome only for now) or the separate [npmx-redirect extension](https://github.com/iaverages/npmx-redirect) for [Chrome](https://chromewebstore.google.com/detail/lbhjgfgpnlihfmobnohoipeljollhlnb) / [Firefox](https://addons.mozilla.org/en-GB/firefox/addon/npmx-redirect/).
120120
121121
#### Not yet supported
122122

@@ -159,6 +159,7 @@ We welcome contributions – please do feel free to explore the project and
159159
- [nxjt](https://nxjt.netlify.app) – npmx Jump To: Quickly navigate to npmx common webpages.
160160
- [npmx-weekly](https://npmx-weekly.trueberryless.org/) – A weekly newsletter for the npmx ecosystem. Add your own content via suggestions in the weekly PR on [GitHub](https://github.com/trueberryless-org/npmx-weekly/pulls?q=is%3Aopen+is%3Apr+label%3A%22%F0%9F%95%94+weekly+post%22).
161161
- [npmx-digest](https://npmx-digest.trueberryless.org/) – An automated news aggregation website that summarizes npmx activity from GitHub and Bluesky every 8 hours.
162+
- [npmx-redirect](https://github.com/iaverages/npmx-redirect) – Browser extension that automatically redirects npmjs.com URLs to npmx.dev.
162163

163164
If you're building something cool, let us know! 🙏
164165

app/components/AppHeader.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,7 @@ onKeyStroke(
180180
for (const link of desktopLinks.value) {
181181
if (link.to && link.keyshortcut && isKeyWithoutModifiers(e, link.keyshortcut)) {
182182
e.preventDefault()
183-
navigateTo(link.to.name)
183+
navigateTo(link.to)
184184
break
185185
}
186186
}

app/components/Code/DirectoryListing.vue

Lines changed: 28 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
<script setup lang="ts">
22
import type { PackageFileTree } from '#shared/types'
33
import type { RouteLocationRaw } from 'vue-router'
4-
import { getFileIcon } from '~/utils/file-icons'
4+
import type { RouteNamedMap } from 'vue-router/auto-routes'
5+
import { ADDITIONAL_ICONS, getFileIcon } from '~/utils/file-icons'
56
67
const props = defineProps<{
78
tree: PackageFileTree[]
89
currentPath: string
910
baseUrl: string
10-
/** Base path segments for the code route (e.g., ['nuxt', 'v', '4.2.0']) */
11-
basePath: string[]
11+
baseRoute: Pick<RouteNamedMap['code'], 'params'>
1212
}>()
1313
1414
// Get the current directory's contents
@@ -41,13 +41,14 @@ const parentPath = computed(() => {
4141
4242
// Build route object for a path
4343
function getCodeRoute(nodePath?: string): RouteLocationRaw {
44-
if (!nodePath) {
45-
return { name: 'code', params: { path: props.basePath as [string, ...string[]] } }
46-
}
47-
const pathSegments = [...props.basePath, ...nodePath.split('/')]
4844
return {
4945
name: 'code',
50-
params: { path: pathSegments as [string, ...string[]] },
46+
params: {
47+
org: props.baseRoute.params.org,
48+
packageName: props.baseRoute.params.packageName,
49+
version: props.baseRoute.params.version,
50+
filePath: nodePath ?? '',
51+
},
5152
}
5253
}
5354
@@ -80,8 +81,15 @@ const bytesFormatter = useBytesFormatter()
8081
:to="getCodeRoute(parentPath || undefined)"
8182
class="py-2 px-4 font-mono text-sm w-full"
8283
no-underline
83-
classicon="i-carbon:folder text-yellow-600"
8484
>
85+
<svg
86+
class="size-[1em] me-1 shrink-0 text-yellow-600"
87+
viewBox="0 0 16 16"
88+
fill="currentColor"
89+
aria-hidden="true"
90+
>
91+
<use :href="`/file-tree-sprite.svg#${ADDITIONAL_ICONS['folder']}`" />
92+
</svg>
8593
<span class="w-full flex justify-self-stretch items-center gap-2"> .. </span>
8694
</LinkBase>
8795
</td>
@@ -98,12 +106,18 @@ const bytesFormatter = useBytesFormatter()
98106
:to="getCodeRoute(node.path)"
99107
class="py-2 px-4 font-mono text-sm w-full"
100108
no-underline
101-
:classicon="
102-
node.type === 'directory'
103-
? 'i-carbon:folder text-yellow-600'
104-
: getFileIcon(node.name)
105-
"
106109
>
110+
<svg
111+
class="size-[1em] me-1 shrink-0"
112+
viewBox="0 0 16 16"
113+
fill="currentColor"
114+
:class="node.type === 'directory' ? 'text-yellow-600' : undefined"
115+
aria-hidden="true"
116+
>
117+
<use
118+
:href="`/file-tree-sprite.svg#${node.type === 'directory' ? ADDITIONAL_ICONS['folder'] : getFileIcon(node.name)}`"
119+
/>
120+
</svg>
107121
<span class="w-full flex justify-self-stretch items-center gap-2">
108122
<span class="flex-1">{{ node.name }}</span>
109123
<span

app/components/Code/FileTree.vue

Lines changed: 29 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
<script setup lang="ts">
22
import type { PackageFileTree } from '#shared/types'
33
import type { RouteLocationRaw } from 'vue-router'
4-
import { getFileIcon } from '~/utils/file-icons'
4+
import type { RouteNamedMap } from 'vue-router/auto-routes'
5+
import { ADDITIONAL_ICONS, getFileIcon } from '~/utils/file-icons'
56
67
const props = defineProps<{
78
tree: PackageFileTree[]
89
currentPath: string
910
baseUrl: string
10-
/** Base path segments for the code route (e.g., ['nuxt', 'v', '4.2.0']) */
11-
basePath: string[]
11+
baseRoute: Pick<RouteNamedMap['code'], 'params'>
1212
depth?: number
1313
}>()
1414
@@ -23,10 +23,14 @@ function isNodeActive(node: PackageFileTree): boolean {
2323
2424
// Build route object for a file path
2525
function getFileRoute(nodePath: string): RouteLocationRaw {
26-
const pathSegments = [...props.basePath, ...nodePath.split('/')]
2726
return {
2827
name: 'code',
29-
params: { path: pathSegments as [string, ...string[]] },
28+
params: {
29+
org: props.baseRoute.params.org,
30+
packageName: props.baseRoute.params.packageName,
31+
version: props.baseRoute.params.version,
32+
filePath: nodePath ?? '',
33+
},
3034
}
3135
}
3236
@@ -57,22 +61,25 @@ watch(
5761
@click="toggleDir(node.path)"
5862
:classicon="isExpanded(node.path) ? 'i-carbon:chevron-down' : 'i-carbon:chevron-right'"
5963
>
60-
<span
61-
class="w-4 h-4 shrink-0"
62-
:class="
63-
isExpanded(node.path)
64-
? 'i-carbon:folder-open text-yellow-500'
65-
: 'i-carbon:folder text-yellow-600'
66-
"
67-
/>
64+
<svg
65+
class="size-[1em] me-1 shrink-0"
66+
:class="isExpanded(node.path) ? 'text-yellow-500' : 'text-yellow-600'"
67+
viewBox="0 0 16 16"
68+
fill="currentColor"
69+
aria-hidden="true"
70+
>
71+
<use
72+
:href="`/file-tree-sprite.svg#${isExpanded(node.path) ? ADDITIONAL_ICONS['folder-open'] : ADDITIONAL_ICONS['folder']}`"
73+
/>
74+
</svg>
6875
<span class="truncate">{{ node.name }}</span>
6976
</ButtonBase>
7077
<CodeFileTree
7178
v-if="isExpanded(node.path) && node.children"
7279
:tree="node.children"
7380
:current-path="currentPath"
7481
:base-url="baseUrl"
75-
:base-path="basePath"
82+
:base-route="baseRoute"
7683
:depth="depth + 1"
7784
/>
7885
</template>
@@ -86,8 +93,15 @@ watch(
8693
class="w-full justify-start! rounded-none! border-none!"
8794
block
8895
:style="{ paddingLeft: `${depth * 12 + 32}px` }"
89-
:classicon="getFileIcon(node.name)"
9096
>
97+
<svg
98+
class="size-[1em] me-1 shrink-0"
99+
viewBox="0 0 16 16"
100+
fill="currentColor"
101+
aria-hidden="true"
102+
>
103+
<use :href="`/file-tree-sprite.svg#${getFileIcon(node.name)}`" />
104+
</svg>
91105
<span class="truncate">{{ node.name }}</span>
92106
</LinkBase>
93107
</template>

app/components/Code/MobileTreeDrawer.vue

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
<script setup lang="ts">
22
import type { PackageFileTree } from '#shared/types'
3+
import type { RouteNamedMap } from 'vue-router/auto-routes'
34
45
defineProps<{
56
tree: PackageFileTree[]
67
currentPath: string
78
baseUrl: string
8-
/** Base path segments for the code route (e.g., ['nuxt', 'v', '4.2.0']) */
9-
basePath: string[]
9+
baseRoute: Pick<RouteNamedMap['code'], 'params'>
1010
}>()
1111
1212
const isOpen = shallowRef(false)
@@ -75,7 +75,7 @@ watch(isOpen, open => (isLocked.value = open))
7575
:tree="tree"
7676
:current-path="currentPath"
7777
:base-url="baseUrl"
78-
:base-path="basePath"
78+
:base-route="baseRoute"
7979
/>
8080
</aside>
8181
</Transition>

app/components/CollapsibleSection.vue

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ onPrehydrate(() => {
2626
const settings = JSON.parse(localStorage.getItem('npmx-settings') || '{}')
2727
const collapsed: string[] = settings?.sidebar?.collapsed || []
2828
for (const id of collapsed) {
29-
if (!document.documentElement.dataset.collapsed?.includes(id)) {
29+
if (!document.documentElement.dataset.collapsed?.split(' ').includes(id)) {
3030
document.documentElement.dataset.collapsed = (
3131
document.documentElement.dataset.collapsed +
3232
' ' +
@@ -38,7 +38,9 @@ onPrehydrate(() => {
3838
3939
onMounted(() => {
4040
if (document?.documentElement) {
41-
isOpen.value = !(document.documentElement.dataset.collapsed?.includes(props.id) ?? false)
41+
isOpen.value = !(
42+
document.documentElement.dataset.collapsed?.split(' ').includes(props.id) ?? false
43+
)
4244
}
4345
})
4446

0 commit comments

Comments
 (0)