Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
3 changes: 3 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Force LF line endings on checkout regardless of OS
# This prevents CRLF lint errors on Windows CI runners
* text=auto eol=lf
16 changes: 13 additions & 3 deletions .github/workflows/vscode-extension-secure-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ on:
- "*.json"
- "*.js"
- "*.ts"
- ".github/workflows/**"
- "!lib/**"
pull_request:
branches: [main, develop]
Expand All @@ -16,6 +17,7 @@ on:
- "*.json"
- "*.js"
- "*.ts"
- ".github/workflows/**"
- "!lib/**"
workflow_dispatch:

Expand Down Expand Up @@ -161,7 +163,7 @@ jobs:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest]
os: [ubuntu-latest, macos-latest, windows-latest]
node-version: [24]

steps:
Expand Down Expand Up @@ -220,13 +222,21 @@ jobs:
./post_compile_fixes.sh
fi

- name: Compile tests
shell: bash
run: npm run compile-tests

- name: Apply essential post-compilation fixes (all platforms)
shell: bash
run: node .github/workflows/scripts/post-compile-fixes-essential.js

- name: Run unit tests
shell: bash
run: |
if [ "${{ runner.os }}" = "Linux" ]; then
xvfb-run -a npm test
xvfb-run -a npm run test:unit
else
npm test
npm run test:unit
fi

- name: Switch to production .vscodeignore for packaging
Expand Down
2 changes: 2 additions & 0 deletions docs/contributor-guide/elastic-search-local-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ The Netskope certificate must be added to Docker's trusted certificates. The app

The error above is about Docker/the OS toolchain. The extension's own telemetry connection to the Elastic Search proxy handles this automatically: the transport loads the operating system trust store (where the Netskope/corporate CA already lives) and merges it with Node's default roots, so the re-signed certificate validates with no per-user configuration.

The Elastic Search transport is loaded lazily during activation, and the `@elastic/elasticsearch` client is bundled into the extension so packaged VSIX builds do not depend on a separate `node_modules` tree at runtime.

This requires a runtime with `tls.getCACertificates` (Node ≥ 22.15). On older hosts the transport falls back to Node's bundled roots only — set `NODE_EXTRA_CA_CERTS` to point at the corporate CA file if the connection then fails.

### Variable Interpolation in `.env`
Expand Down
12 changes: 12 additions & 0 deletions docs/reference/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,8 @@ This is useful when bundle files have been manually deleted but the lockfile sti

## Hub Management

Hub management commands are available from the Command Palette and activate the extension on demand when invoked directly.

| Command | Title | Description |
|---------|-------|-------------|
| `promptregistry.importHub` | Import Hub | Import a hub configuration |
Expand Down Expand Up @@ -167,6 +169,16 @@ This is useful when bundle files have been manually deleted but the lockfile sti
| `promptregistry.openItemRepository` | Open Repository | Open an item's repository in a browser |
| `promptRegistry.resetFirstRun` | Reset First Run | Reset first-run state to re-trigger hub selection dialog |

## Chat Integration

The extension contributes the `@prompts` chat participant for GitHub Copilot Chat.

**Participant:** `@prompts`

**Slash commands:**
- `/help` — Show available prompt commands
- `/list` — List installed prompts

## See Also

- [Settings Reference](./settings.md) — Extension configuration options
Expand Down
26 changes: 23 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,29 @@
"main": "./dist/extension.js",
"icon": "icon.png",
"activationEvents": [
"onStartupFinished"
"onStartupFinished",
"onCommand:promptregistry.switchHub"
],
"contributes": {
"chatParticipants": [
{
"id": "prompts",
"name": "prompts",
"fullName": "AI Primitives Hub",
"description": "Use installed prompt bundles in chat",
"isSticky": true,
"commands": [
{
"name": "help",
"description": "Show available prompt commands"
},
{
"name": "list",
"description": "List installed prompts"
}
]
}
],
"chatSkills": [
{
"path": "./resources/skills/prompt-registry-helper/SKILL.md"
Expand Down Expand Up @@ -718,12 +738,12 @@
"lint:fix": "eslint src test --fix && npm run lint:fix -w lib",
"test": "npm run test:all",
"test:one": "node scripts/run-single-test.js",
"test:unit": "npx mocha --ui tdd --require ./test/mocha.setup.js --require ./test/unit.setup.js 'test-dist/test/**/*.test.js' --ignore 'test-dist/test/suite/**/*.test.js' --timeout 5000",
"test:unit": "npx mocha --ui tdd --require ./test/mocha.setup.js --require ./test/unit.setup.js \"test-dist/test/**/*.test.js\" --ignore \"test-dist/test/suite/**/*.test.js\" --timeout 5000",
"test:integration": "npm run compile-tests && node ./test/runExtensionTests.js",
"test:all": "npm run compile-tests && npm run test:unit && npm run test:integration",
"test:coverage": "npm run compile-tests && c8 npm run test:all",
"test:coverage:report": "c8 report --reporter=html --reporter=text --reporter=json",
"test:coverage:unit": "npm run compile-tests && c8 --reporter=html --reporter=text mocha --ui tdd --require ./test/mocha.setup.js --require ./test/unit.setup.js 'test-dist/test/**/*.test.js' --ignore 'test-dist/test/suite/**/*.test.js' --timeout 5000",
"test:coverage:unit": "npm run compile-tests && c8 --reporter=html --reporter=text mocha --ui tdd --require ./test/mocha.setup.js --require ./test/unit.setup.js \"test-dist/test/**/*.test.js\" --ignore \"test-dist/test/suite/**/*.test.js\" --timeout 5000",
"test:coverage:integration": "npm run compile-tests && c8 --reporter=html --reporter=text npm run test:integration",
"coverage:clean": "rimraf coverage .nyc_output .c8_output",
"ignore:status": "node scripts/package-helpers.js status",
Expand Down
4 changes: 3 additions & 1 deletion src/commands/scaffold-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,9 @@ export class ScaffoldCommand {
// Otherwise treat as extensionPath and append templates/scaffolds path
let templatesPath: string;
if (extensionPathOrTemplateRoot) {
templatesPath = extensionPathOrTemplateRoot.includes('templates/scaffolds') ? extensionPathOrTemplateRoot : path.join(extensionPathOrTemplateRoot, 'templates/scaffolds', scaffoldType);
// Normalize to forward slashes for cross-platform path comparison
const normalizedPath = extensionPathOrTemplateRoot.replace(/\\/g, '/');
templatesPath = normalizedPath.includes('templates/scaffolds') ? extensionPathOrTemplateRoot : path.join(extensionPathOrTemplateRoot, 'templates/scaffolds', scaffoldType);
} else {
templatesPath = path.join(__dirname, '../templates/scaffolds', scaffoldType);
}
Expand Down
15 changes: 7 additions & 8 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,6 @@ import {
import {
AutoUpdateService,
} from './services/auto-update-service';
import {
ElasticSearchTransport,
} from './services/elastic-search-transport';
import {
HubManager,
} from './services/hub-manager';
Expand Down Expand Up @@ -210,13 +207,14 @@ export class PromptRegistryExtension {
/**
* Initialize telemetry service and subscribe to events.
*/
private initializeTelemetry(): void {
private async initializeTelemetry(): Promise<void> {
try {
this.telemetryService = TelemetryService.getInstance();
this.telemetryService.subscribeToRegistryEvents(this.registryManager);
this.telemetryService.addTransport(new OutputChannelTransport());

if (this.hubManager) {
const { ElasticSearchTransport } = await import('./services/elastic-search-transport');
const esTransport = new ElasticSearchTransport();
esTransport.subscribeToHubEvents(this.hubManager);
this.telemetryService.addTransport(esTransport);
Expand Down Expand Up @@ -1556,6 +1554,10 @@ export class PromptRegistryExtension {
try {
this.logger.info('Activating AI Primitives Hub extension...');

// Register commands first so command-triggered activation can still succeed
// even if later startup work fails.
this.registerCommands();

// Initialize McpConfigLocator for profile support
McpConfigLocator.initialize(this.context);

Expand All @@ -1568,9 +1570,6 @@ export class PromptRegistryExtension {
// Run data migrations (idempotent, skips if already completed)
await this.runMigrations();

// Register commands
this.registerCommands();

// Initialize UI components
await this.initializeUI();

Expand All @@ -1587,7 +1586,7 @@ export class PromptRegistryExtension {
await this.initializeUpdateSystem();

// Initialize telemetry service
this.initializeTelemetry();
await this.initializeTelemetry();

// Initialize repository-level installation services
await this.initializeRepositoryServices();
Expand Down
6 changes: 4 additions & 2 deletions test/commands/scaffold-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -390,11 +390,13 @@ suite('ScaffoldCommand', () => {

suite('Error Handling', () => {
test('should throw error for invalid path', async () => {
const invalidPath = '/invalid/path/that/does/not/exist/and/cannot/be/created/abc123xyz';
const fileParent = path.join(testDir, 'not-a-directory');
fs.writeFileSync(fileParent, 'test content');
const invalidPath = path.join(fileParent, 'child');

await assert.rejects(
async () => await scaffoldCommand.execute(invalidPath),
/Cannot create directory|permission denied|EACCES|ENOENT/i
/Cannot create directory|permission denied|EACCES|ENOENT|ENOTDIR/i
);
});

Expand Down
27 changes: 27 additions & 0 deletions test/config/package-configuration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,33 @@ suite('Package Configuration - Update Check Settings', () => {
);
});

test('should activate on switchHub command execution', () => {
assert.ok(Array.isArray(packageJson.activationEvents), 'package.json should define activationEvents array');
assert.ok(
packageJson.activationEvents.includes('onCommand:promptregistry.switchHub'),
'switchHub should activate the extension when executed directly'
);
});

test('should contribute the prompts chat participant', () => {
assert.ok(packageJson.contributes, 'package.json should have contributes section');
assert.ok(Array.isArray(packageJson.contributes.chatParticipants), 'contributes should define chatParticipants');

const promptsParticipant = packageJson.contributes.chatParticipants.find((participant: any) => participant.id === 'prompts');

assert.ok(promptsParticipant, 'package.json should contribute the prompts chat participant');
assert.strictEqual(promptsParticipant.name, 'prompts', 'prompts chat participant should use the @prompts mention name');
assert.ok(Array.isArray(promptsParticipant.commands), 'prompts chat participant should contribute slash commands');
assert.ok(
promptsParticipant.commands.some((command: any) => command.name === 'help'),
'prompts chat participant should contribute a help slash command'
);
assert.ok(
promptsParticipant.commands.some((command: any) => command.name === 'list'),
'prompts chat participant should contribute a list slash command'
);
});

test('should have configuration section', () => {
assert.ok(packageJson.contributes, 'package.json should have contributes section');
assert.ok(packageJson.contributes.configuration, 'contributes should have configuration section');
Expand Down
20 changes: 20 additions & 0 deletions test/config/webpack-configuration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import * as assert from 'node:assert';
import * as path from 'node:path';

suite('Webpack Configuration', () => {
test('should bundle @elastic/elasticsearch into the extension', () => {
const webpackConfig = require(path.join(process.cwd(), 'webpack.config.js'));

assert.strictEqual(webpackConfig.externals['@elastic/elasticsearch'], undefined);
});

test('should externalize apache-arrow native bindings', () => {
const webpackConfig = require(path.join(process.cwd(), 'webpack.config.js'));

assert.strictEqual(
webpackConfig.externals['apache-arrow/Arrow.node'],
'commonjs apache-arrow/Arrow.node',
'apache-arrow native bindings should stay external because webpack cannot bundle .node modules'
);
});
});
2 changes: 1 addition & 1 deletion test/e2e/github-scaffold-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ suite('E2E: GitHub Scaffold Integration Tests', () => {
teardown(() => {
// Clean up test directory
if (fs.existsSync(testDir)) {
fs.rmSync(testDir, { recursive: true, force: true });
fs.rmSync(testDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
}
});

Expand Down
37 changes: 32 additions & 5 deletions test/services/repository-scope-service.property.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ suite('RepositoryScopeService Property Tests', () => {

// Path must include .github/
assert.ok(
targetPath.includes('.github/'),
targetPath.includes(path.join('.github', '')),
`Target path should include .github/, got: ${targetPath}`
);

Expand Down Expand Up @@ -539,19 +539,46 @@ suite('RepositoryScopeService Property Tests', () => {
}

// Create deployment manifest
const manifest = `id: ${bundleId}
const manifest = `id: ${JSON.stringify(bundleId)}
version: "1.0.0"
prompts:
- id: ${skillName}
name: ${skillName}
file: skills/${skillName}/SKILL.md
- id: ${JSON.stringify(skillName)}
name: ${JSON.stringify(skillName)}
file: ${JSON.stringify(`skills/${skillName}/SKILL.md`)}
type: skill`;

fs.writeFileSync(path.join(bundlePath, 'deployment-manifest.yml'), manifest);

return bundlePath;
};

test('supports skill names that require YAML quoting', async () => {
const skillName = '-';
const files = [
{ relativePath: 'SKILL.md', content: '# -\nSkill description' },
{ relativePath: 'lib/helper.json', content: '{"ok":true}' }
];

if (fs.existsSync(tempDir)) {
fs.rmSync(tempDir, { recursive: true, force: true });
}
fs.mkdirSync(workspaceRoot, { recursive: true });
fs.mkdirSync(path.join(tempDir, 'bundles'), { recursive: true });

service = new RepositoryScopeService(workspaceRoot, mockStorage);

const bundleId = `skill-bundle-${skillName}`;
const bundlePath = createSkillBundle(bundleId, skillName, files);

mockStorage.getInstalledBundle.resolves(createMockInstalledBundle(bundleId, bundlePath, 'commit'));

await service.syncBundle(bundleId, bundlePath);

const targetSkillDir = path.join(workspaceRoot, '.github', 'skills', skillName);
assert.ok(fs.existsSync(path.join(targetSkillDir, 'SKILL.md')));
assert.ok(fs.existsSync(path.join(targetSkillDir, 'lib', 'helper.json')));
});

/**
* Helper to read all files from a directory recursively
* @param dir
Expand Down
5 changes: 4 additions & 1 deletion test/services/repository-scope-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1181,7 +1181,10 @@ prompts:

test('should use getRepositoryTargetDirectory for skill type', () => {
const targetPath = service.getTargetPath('skill', 'test-skill');
assert.ok(targetPath.includes('.github/skills/'), 'Skill target path should include .github/skills/');
assert.ok(
targetPath.includes(path.join('.github', 'skills', '')),
'Skill target path should include .github/skills/'
);
});
});
});
2 changes: 1 addition & 1 deletion test/services/user-scope-service.wsl.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ suite('UserScopeService - WSL Support', () => {
const service = new UserScopeService(mockContext);
const promptsDir = await getResolvedPromptsDir(service, globalStorage);

assert.ok(promptsDir.includes('/Code/'), `Expected path to include '/Code/' but got: ${promptsDir}`);
assert.ok(promptsDir.includes(path.join('Code', '')), `Expected path to include Code directory but got: ${promptsDir}`);
assert.ok(!promptsDir.includes('Insiders'), `Expected path to NOT include 'Insiders' but got: ${promptsDir}`);
});

Expand Down
13 changes: 4 additions & 9 deletions test/storage/hub-storage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,20 +119,15 @@ suite('HubStorage - TDD', () => {
});

test('should handle save errors gracefully', async () => {
// Create a read-only directory
const readOnlyDir = path.join(tempDir, 'readonly');
fs.mkdirSync(readOnlyDir, { recursive: true });
fs.chmodSync(readOnlyDir, 0o444);
const fileStoragePath = path.join(tempDir, 'not-a-directory');
fs.writeFileSync(fileStoragePath, 'test content');

const readOnlyStorage = new HubStorage(readOnlyDir);
const invalidStorage = new HubStorage(fileStoragePath);

await assert.rejects(
async () => await readOnlyStorage.saveHub('test', testHubConfig, testHubReference),
async () => await invalidStorage.saveHub('test', testHubConfig, testHubReference),
/Failed to save hub/
);

// Restore permissions for cleanup
fs.chmodSync(readOnlyDir, 0o755);
});

test('should overwrite existing hub config', async () => {
Expand Down
Loading
Loading