Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ browser acts as the runtime host for render, lint, and typecheck flows.
- Live IDE: https://knightedcodemonkey.github.io/develop/
- Source repository: https://github.com/knightedcodemonkey/develop

## BYOT Guide

- GitHub PAT setup and usage: [docs/byot.md](docs/byot.md)

## License

MIT
67 changes: 67 additions & 0 deletions docs/byot.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# BYOT Setup for GitHub in @knighted/develop

This guide explains how to create and use a fine-grained GitHub Personal Access Token (PAT) for the BYOT flow in `@knighted/develop`.

## What BYOT does in the app

When the AI/BYOT feature is enabled, the token is used to:

- authenticate GitHub API requests
- load repositories where you have write access
- let you choose which repository to work with

As additional AI/PR features roll out, the same token is also used for model and repository operations that require the configured permissions.

## Privacy and storage behavior

- Your token is stored only in your browser `localStorage`.
- The token is never sent to any service except the GitHub endpoints required by the feature.
- You can remove it at any time using the delete button in the BYOT controls.

## Enable the BYOT feature

Use one of these options:

1. Add `?feature-ai=true` to the app URL.
2. Set `localStorage` key `knighted:develop:feature:ai-assistant` to `true`.

## Create a fine-grained PAT

Create a fine-grained PAT in GitHub settings and grant the permissions below.

- Repository permissions screenshot: [docs/media/byot-repo-perms.png](docs/media/byot-repo-perms.png)
- Models permission screenshot: [docs/media/byot-model-perms.png](docs/media/byot-model-perms.png)

<img src="media/byot-repo-perms.png" alt="Repository PAT permissions" width="560" />
<img src="media/byot-model-perms.png" alt="Models PAT permission" width="560" />

### Repository permissions

- Contents: Read and write
- Pull requests: Read and write
- Metadata: Read-only (required)

### Account permissions

- Models: Read-only

### Repository access scope

Use either of these scopes depending on your needs:

- Only select repositories
- All repositories

`@knighted/develop` will only show repositories where your token has write access.

## Recommended setup flow

1. Create token with the permissions above.
2. Open `@knighted/develop` with `?feature-ai=true`.
3. Paste token into the BYOT input and click add.
4. Verify repository list loads.
5. Select your target repository.

## Screenshots

The screenshots above show the recommended repository and account permission settings.
Binary file added docs/media/byot-model-perms.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/media/byot-repo-perms.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
74 changes: 71 additions & 3 deletions playwright/app.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,17 @@ import type { Page } from '@playwright/test'
const webServerMode = process.env.PLAYWRIGHT_WEB_SERVER_MODE ?? 'dev'
const appEntryPath = webServerMode === 'preview' ? '/index.html' : '/src/index.html'

const waitForInitialRender = async (page: Page) => {
await page.goto(appEntryPath)
const waitForAppReady = async (page: Page, path = appEntryPath) => {
await page.goto(path)
await expect(page.getByRole('heading', { name: '@knighted/develop' })).toBeVisible()
await expect(page.locator('#status')).toHaveText('Rendered')
await expect(page.locator('#cdn-loading')).toHaveAttribute('hidden', '')
}

const waitForInitialRender = async (page: Page) => {
await waitForAppReady(page)
await expect(page.locator('#status')).toHaveText('Rendered')
}

const expectPreviewHasRenderedContent = async (page: Page) => {
const previewHost = page.locator('#preview-host')
await expect(previewHost.locator('pre')).toHaveCount(0)
Expand Down Expand Up @@ -126,6 +130,70 @@ const expectCollapseButtonState = async (
}
}

test('BYOT controls stay hidden when feature flag is disabled', async ({ page }) => {
await waitForAppReady(page)

const byotControls = page.locator('#github-ai-controls')
await expect(byotControls).toHaveAttribute('hidden', '')
await expect(byotControls).toBeHidden()
})

test('BYOT controls render when feature flag is enabled by query param', async ({
page,
}) => {
await waitForAppReady(page, `${appEntryPath}?feature-ai=true`)

const byotControls = page.locator('#github-ai-controls')
await expect(byotControls).toBeVisible()
await expect(page.locator('#github-token-input')).toBeVisible()
await expect(page.locator('#github-token-add')).toBeVisible()
})

test('BYOT remembers selected repository across reloads', async ({ page }) => {
await page.route('https://api.github.com/user/repos**', async route => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([
{
id: 2,
owner: { login: 'knightedcodemonkey' },
name: 'develop',
full_name: 'knightedcodemonkey/develop',
default_branch: 'main',
permissions: { push: true },
},
{
id: 1,
owner: { login: 'knightedcodemonkey' },
name: 'css',
full_name: 'knightedcodemonkey/css',
default_branch: 'main',
permissions: { push: true },
},
]),
})
})

await waitForAppReady(page, `${appEntryPath}?feature-ai=true`)

await page.locator('#github-token-input').fill('github_pat_fake_1234567890')
await page.locator('#github-token-add').click()

const repoSelect = page.locator('#github-repo-select')
await expect(repoSelect).toBeEnabled()
await expect(page.locator('#status')).toHaveText('Loaded 2 writable repositories')

await repoSelect.selectOption('knightedcodemonkey/develop')
await expect(repoSelect).toHaveValue('knightedcodemonkey/develop')

await page.reload()
await expect(page.getByRole('heading', { name: '@knighted/develop' })).toBeVisible()
await expect(page.locator('#github-token-add')).toBeHidden()
await expect(page.locator('#github-token-delete')).toBeVisible()
await expect(repoSelect).toHaveValue('knightedcodemonkey/develop')
})

test('renders default playground preview', async ({ page }) => {
await waitForInitialRender(page)

Expand Down
24 changes: 24 additions & 0 deletions src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import {
import { createCodeMirrorEditor } from './modules/editor-codemirror.js'
import { defaultCss, defaultJsx, defaultReactJsx } from './modules/defaults.js'
import { createDiagnosticsUiController } from './modules/diagnostics-ui.js'
import { isAiAssistantFeatureEnabled } from './modules/feature-flags.js'
import { createGitHubByotControls } from './modules/github-byot-controls.js'
import { createLayoutThemeController } from './modules/layout-theme.js'
import { createLintDiagnosticsController } from './modules/lint-diagnostics.js'
import { createPreviewBackgroundController } from './modules/preview-background.js'
Expand All @@ -15,6 +17,13 @@ import { createTypeDiagnosticsController } from './modules/type-diagnostics.js'

const statusNode = document.getElementById('status')
const appGrid = document.querySelector('.app-grid')
const githubAiControls = document.getElementById('github-ai-controls')
const githubTokenInput = document.getElementById('github-token-input')
const githubTokenInfo = document.getElementById('github-token-info')
const githubTokenAdd = document.getElementById('github-token-add')
const githubTokenDelete = document.getElementById('github-token-delete')
const githubRepoWrap = document.getElementById('github-repo-wrap')
const githubRepoSelect = document.getElementById('github-repo-select')
const appGridLayoutButtons = document.querySelectorAll('[data-app-grid-layout]')
const appThemeButtons = document.querySelectorAll('[data-app-theme]')
const editorToolsButtons = document.querySelectorAll('[data-editor-tools-toggle]')
Expand Down Expand Up @@ -384,6 +393,21 @@ const {
updateUiIssueIndicators,
} = diagnosticsUi

const aiAssistantFeatureEnabled = isAiAssistantFeatureEnabled()

createGitHubByotControls({
featureEnabled: aiAssistantFeatureEnabled,
controlsRoot: githubAiControls,
tokenInput: githubTokenInput,
tokenInfoButton: githubTokenInfo,
tokenAddButton: githubTokenAdd,
tokenDeleteButton: githubTokenDelete,
repoSelect: githubRepoSelect,
repoWrap: githubRepoWrap,
onRepositoryChange: () => {},
setStatus,
})

const getStyleEditorLanguage = mode => {
if (mode === 'less') return 'less'
if (mode === 'sass') return 'sass'
Expand Down
66 changes: 66 additions & 0 deletions src/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,72 @@ <h1>

<main class="app-grid">
<div class="app-grid-layout-controls" role="group" aria-label="App grid layout">
<div
class="app-grid-ai-controls"
id="github-ai-controls"
role="group"
aria-label="GitHub AI controls"
hidden
>
<div class="github-token-control-wrap">
<button
class="hint-icon shadow-hint github-token-info"
id="github-token-info"
type="button"
aria-label="About GitHub token"
data-tooltip="This token is stored only in your browser and is sent only to GitHub APIs you invoke."
Comment thread
knightedcodemonkey marked this conversation as resolved.
>
i
</button>
Comment thread
knightedcodemonkey marked this conversation as resolved.
<label class="sr-only" for="github-token-input">GitHub token</label>
<input
class="github-token-input"
id="github-token-input"
type="text"
autocomplete="off"
autocapitalize="off"
spellcheck="false"
placeholder="GitHub BYOT"
aria-label="GitHub token"
/>
Comment thread
knightedcodemonkey marked this conversation as resolved.
<button
class="layout-toggle github-token-add"
id="github-token-add"
type="button"
aria-label="Add GitHub token"
title="Add GitHub token"
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 5v14"></path>
<path d="M5 12h14"></path>
</svg>
</button>
<button
class="layout-toggle github-token-delete"
id="github-token-delete"
type="button"
aria-label="Delete GitHub token"
title="Delete GitHub token"
hidden
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M4 7h16"></path>
<path d="M10 11v6"></path>
<path d="M14 11v6"></path>
<path d="M6 7l1 12a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-12"></path>
<path d="M9 7V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v3"></path>
</svg>
</button>
</div>

<div class="github-repo-wrap" id="github-repo-wrap" hidden>
<label class="sr-only" for="github-repo-select">Repository</label>
<select id="github-repo-select" aria-label="Repository" disabled>
<option selected>Connect a token to load repositories</option>
</select>
</div>
</div>

<div class="app-grid-diagnostics-controls" role="group" aria-label="Diagnostics">
<button
class="diagnostics-toggle"
Expand Down
64 changes: 64 additions & 0 deletions src/modules/feature-flags.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
const aiFeatureStorageKey = 'knighted:develop:feature:ai-assistant'
const aiFeatureQueryKey = 'feature-ai'

const parseBooleanLikeValue = value => {
if (typeof value !== 'string') {
return null
}

const normalized = value.trim().toLowerCase()

if (['1', 'true', 'on', 'yes', 'enabled'].includes(normalized)) {
return true
}

if (['0', 'false', 'off', 'no', 'disabled'].includes(normalized)) {
return false
}

return null
}

const readBooleanFromLocalStorage = key => {
try {
const storedValue = localStorage.getItem(key)
return parseBooleanLikeValue(storedValue)
} catch {
return null
}
}

const readBooleanFromQueryParam = key => {
if (typeof window === 'undefined') {
return null
}

const params = new URLSearchParams(window.location.search)
if (!params.has(key)) {
return null
}

return parseBooleanLikeValue(params.get(key))
}

export const isAiAssistantFeatureEnabled = () => {
const queryValue = readBooleanFromQueryParam(aiFeatureQueryKey)
if (queryValue !== null) {
return queryValue
}

const localStorageValue = readBooleanFromLocalStorage(aiFeatureStorageKey)
if (localStorageValue !== null) {
return localStorageValue
}

return false
}

export const setAiAssistantFeatureEnabled = isEnabled => {
try {
localStorage.setItem(aiFeatureStorageKey, isEnabled ? 'true' : 'false')
} catch {
/* noop */
}
}
Loading
Loading