Skip to content
Merged
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
5 changes: 2 additions & 3 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ if (process.argv.includes('--no-color')) {
}

import { spawn } from 'node:child_process';
import { existsSync } from 'node:fs';
import { resolve } from 'node:path';
import type { Config } from '@inkeep/open-knowledge-server';
import { Command } from 'commander';
Expand All @@ -35,7 +34,7 @@ import { shareCommand } from './commands/share/index.ts';
import { sharingCommand } from './commands/sharing/index.ts';
import {
decideSingleFileTarget,
hasMarkdownExtension,
isFileishTarget,
scanRootArgv,
} from './commands/single-file-dispatch.ts';
import { createRealSingleFileOpenDeps, runSingleFileOpen } from './commands/single-file-open.ts';
Expand Down Expand Up @@ -186,7 +185,7 @@ Examples:
const knownSubcommands = new Set(program.commands.map((c) => c.name()));
const target = decideSingleFileTarget(scanned.operands, {
knownSubcommands,
isFileish: (t) => hasMarkdownExtension(t) || existsSync(resolve(baseDir, t)),
isFileish: (t) => isFileishTarget(resolve(baseDir, t), t),
});
if (target !== null) {
const code = await runSingleFileOpen(
Expand Down
82 changes: 76 additions & 6 deletions packages/cli/src/commands/open.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ function makeDeps(overrides: Partial<OpenDeps> = {}): {
const deps: OpenDeps = {
detectBundlePath: () => null,
resolveBaseUrl: () => null,
classifyName: () => 'doc',
openTarget: (t) => opened.push(t),
log: (m) => logs.push(m),
error: (m) => errors.push(m),
Expand Down Expand Up @@ -49,26 +50,95 @@ describe('runOpen', () => {
expect(errors).toHaveLength(1);
});

test('folder takes the browser route even when a desktop bundle is present', () => {
test('folder with a desktop bundle → folder= deep link, exit 0', () => {
const { deps, opened } = makeDeps({
detectBundlePath: () => '/Applications/OpenKnowledge.app',
});
const code = runOpen('specs/foo/', { project: '/p' }, deps);
expect(code).toBe(0);
expect(opened).toEqual(['openknowledge://open?project=%2Fp&folder=specs%2Ffoo']);
});

test('folder, no bundle but UI running → browser folder route, exit 0', () => {
const { deps, opened } = makeDeps({
detectBundlePath: () => null,
resolveBaseUrl: () => 'http://localhost:5173',
});
const code = runOpen('specs/foo', { folder: true, project: '/p' }, deps);
const code = runOpen('specs/foo/', { project: '/p' }, deps);
expect(code).toBe(0);
expect(opened).toEqual(['http://localhost:5173/#/specs/foo/']);
});

test('trailing slash infers folder intent without --folder', () => {
const { deps, opened } = makeDeps({ resolveBaseUrl: () => 'http://localhost:5173' });
test('auto-detects a folder from disk (no trailing slash needed)', () => {
const { deps, opened } = makeDeps({
detectBundlePath: () => '/Applications/OpenKnowledge.app',
classifyName: () => 'folder',
});
const code = runOpen('specs/foo', { project: '/p' }, deps);
expect(code).toBe(0);
expect(opened).toEqual(['openknowledge://open?project=%2Fp&folder=specs%2Ffoo']);
});

test('trailing slash infers folder intent even when disk classify says doc', () => {
const { deps, opened } = makeDeps({
resolveBaseUrl: () => 'http://localhost:5173',
classifyName: () => 'doc',
});
const code = runOpen('specs/foo/', {}, deps);
expect(code).toBe(0);
expect(opened).toEqual(['http://localhost:5173/#/specs/foo/']);
});

test('skill with a desktop bundle → rides doc=__skill__/<scope>/<name> deep link', () => {
const { deps, opened } = makeDeps({
detectBundlePath: () => '/Applications/OpenKnowledge.app',
});
const code = runOpen('trip-log', { skill: true, project: '/p' }, deps);
expect(code).toBe(0);
expect(opened).toEqual([
'openknowledge://open?project=%2Fp&doc=__skill__%2Fproject%2Ftrip-log',
]);
});

test('skill --scope global, no bundle but UI running → browser skill route', () => {
const { deps, opened } = makeDeps({ resolveBaseUrl: () => 'http://localhost:5173' });
const code = runOpen('trip-log', { skill: true, scope: 'global', project: '/p' }, deps);
expect(code).toBe(0);
expect(opened).toEqual(['http://localhost:5173/#/__skill__/global/trip-log']);
});

test('skill with an invalid --scope → error, exit 1', () => {
const { deps, errors } = makeDeps({
detectBundlePath: () => '/Applications/OpenKnowledge.app',
});
const code = runOpen('trip-log', { skill: true, scope: 'nonsense', project: '/p' }, deps);
expect(code).toBe(1);
expect(errors).toHaveLength(1);
});

test('skill, neither desktop nor UI → error, exit 1, nothing opened', () => {
const { deps, opened, errors } = makeDeps();
const code = runOpen('trip-log', { skill: true, project: '/p' }, deps);
expect(code).toBe(1);
expect(opened).toEqual([]);
expect(errors).toHaveLength(1);
});

test('skill name with a traversal/unsafe segment → error before any open', () => {
const { deps, opened, errors } = makeDeps({
detectBundlePath: () => '/Applications/OpenKnowledge.app',
});
for (const bad of ['../../etc', '/abs', 'a\\b']) {
const code = runOpen(bad, { skill: true, project: '/p' }, deps);
expect(code).toBe(1);
}
expect(opened).toEqual([]);
expect(errors.length).toBeGreaterThanOrEqual(3);
});

test('folder with no UI running → error, exit 1', () => {
const { deps, errors } = makeDeps({ resolveBaseUrl: () => null });
const code = runOpen('specs/foo', { folder: true, project: '/p' }, deps);
const code = runOpen('specs/foo/', { project: '/p' }, deps);
expect(code).toBe(1);
expect(errors).toHaveLength(1);
});
Expand Down Expand Up @@ -97,7 +167,7 @@ describe('runOpen', () => {

test('rejects unsafe folder names too', () => {
const { deps, opened, errors } = makeDeps({ resolveBaseUrl: () => 'http://localhost:5173' });
const code = runOpen('specs/..', { folder: true, project: '/p' }, deps);
const code = runOpen('specs/..', { project: '/p' }, deps);
expect(code).toBe(1);
expect(opened).toEqual([]);
expect(errors).toHaveLength(1);
Expand Down
131 changes: 99 additions & 32 deletions packages/cli/src/commands/open.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,27 @@
import { spawn as nodeSpawn } from 'node:child_process';
import { resolve } from 'node:path';
import { statSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { MANAGED_ARTIFACT_SCOPES, type SkillScope } from '@inkeep/open-knowledge-core';
import {
encodeDocName,
encodeFolderRoute,
encodeSkillRoute,
resolveLockDir,
resolveUiInfo,
} from '@inkeep/open-knowledge-server';
import { Command } from 'commander';
import { createRealDetectDeps, type DetectResult, detectDesktop } from './desktop-dispatch.ts';

export interface OpenOptions {
folder?: boolean;
skill?: boolean;
scope?: string;
project?: string;
}

export interface OpenDeps {
detectBundlePath: () => string | null;
resolveBaseUrl: (projectDir: string) => string | null;
classifyName: (projectDir: string, name: string) => 'doc' | 'folder';
openTarget: (target: string) => void;
log: (message: string) => void;
error: (message: string) => void;
Expand All @@ -34,6 +39,20 @@ export function createRealOpenDeps(
return {
detectBundlePath: () => detect().bundlePath ?? null,
resolveBaseUrl: (projectDir) => resolveUiInfo({ lockDir: resolveLockDir(projectDir) }).baseUrl,
classifyName: (projectDir, name) => {
const abs = join(projectDir, name);
try {
return statSync(abs).isDirectory() ? 'folder' : 'doc';
} catch (err) {
const code = (err as { code?: string } | null)?.code;
if (code !== 'ENOENT' && code !== 'ENOTDIR') {
process.stderr.write(
`[ok open] statSync failed for ${abs} (${code ?? 'unknown'}); treating as a doc\n`,
);
}
return 'doc';
}
},
openTarget: (target) => {
const child = nodeSpawn('open', [target], {
detached: true,
Expand All @@ -47,74 +66,122 @@ export function createRealOpenDeps(
};
}

function isUnsafeName(name: string): boolean {
return name.startsWith('/') || name.includes('\\') || name.split('/').includes('..');
}

function noTargetError(deps: OpenDeps): number {
deps.error(
'No OpenKnowledge desktop app found and no UI is running. ' +
'Install OK Desktop, or start a UI with `ok ui`, then retry.',
);
return 1;
}

function openDesktopDeepLink(
projectDir: string,
param: 'doc' | 'folder',
target: string,
deps: OpenDeps,
): void {
const deepLink = `openknowledge://open?project=${encodeURIComponent(
projectDir,
)}&${param}=${encodeURIComponent(target)}`;
deps.openTarget(deepLink);
}

export function runOpen(name: string, options: OpenOptions, deps: OpenDeps): number {
const projectDir = resolve(options.project ?? process.cwd());
const isFolder = options.folder === true || /\/+$/.test(name);
const cleanName = name.replace(/\/+$/, '');

if (!cleanName) {
deps.error('Nothing to open: pass a doc path (e.g. `ok open specs/foo/SPEC`).');
deps.error(
'Nothing to open: pass a doc, folder, or skill name (e.g. `ok open specs/foo/SPEC`).',
);
return 1;
}

if (
cleanName.startsWith('/') ||
cleanName.includes('\\') ||
cleanName.split('/').includes('..')
) {
if (isUnsafeName(cleanName)) {
deps.error(
`Invalid name "${cleanName}": must be a relative path with no '..' segments, leading '/', or backslashes.`,
);
return 1;
}

if (isFolder) {
const baseUrl = deps.resolveBaseUrl(projectDir);
if (!baseUrl) {
if (options.skill === true) {
const scope = (options.scope ?? 'project') as SkillScope;
if (!(MANAGED_ARTIFACT_SCOPES as readonly string[]).includes(scope)) {
deps.error(
`No OpenKnowledge UI is running for ${projectDir}. Folder preview requires a running UI — start one with \`ok ui\`, then retry.`,
`Invalid --scope "${options.scope}": expected one of ${MANAGED_ARTIFACT_SCOPES.join(', ')}.`,
);
return 1;
}
const url = `${baseUrl}/#/${encodeFolderRoute(cleanName)}`;
deps.openTarget(url);
deps.log(`Opening folder ${cleanName} in your browser: ${url}`);
return 0;
const bundlePath = deps.detectBundlePath();
if (bundlePath) {
openDesktopDeepLink(projectDir, 'doc', `__skill__/${scope}/${cleanName}`, deps);
deps.log(`Opening skill ${cleanName} (${scope}) in the OpenKnowledge desktop app.`);
return 0;
}
const baseUrl = deps.resolveBaseUrl(projectDir);
if (baseUrl) {
const url = `${baseUrl}/#/${encodeSkillRoute(scope, cleanName)}`;
deps.openTarget(url);
deps.log(`Opening skill ${cleanName} (${scope}) in your browser: ${url}`);
return 0;
}
return noTargetError(deps);
}

const isFolder = /\/+$/.test(name) || deps.classifyName(projectDir, cleanName) === 'folder';

const bundlePath = deps.detectBundlePath();
if (isFolder) {
if (bundlePath) {
openDesktopDeepLink(projectDir, 'folder', cleanName, deps);
deps.log(`Opening folder ${cleanName} in the OpenKnowledge desktop app.`);
return 0;
}
const baseUrl = deps.resolveBaseUrl(projectDir);
if (baseUrl) {
const url = `${baseUrl}/#/${encodeFolderRoute(cleanName)}`;
deps.openTarget(url);
deps.log(`Opening folder ${cleanName} in your browser: ${url}`);
return 0;
}
return noTargetError(deps);
}

if (bundlePath) {
const deepLink = `openknowledge://open?project=${encodeURIComponent(
projectDir,
)}&doc=${encodeURIComponent(cleanName)}`;
deps.openTarget(deepLink);
openDesktopDeepLink(projectDir, 'doc', cleanName, deps);
deps.log(`Opening ${cleanName} in the OpenKnowledge desktop app.`);
return 0;
}

const baseUrl = deps.resolveBaseUrl(projectDir);
if (baseUrl) {
const url = `${baseUrl}/#/${encodeDocName(cleanName)}`;
deps.openTarget(url);
deps.log(`Opening ${cleanName} in your browser: ${url}`);
return 0;
}

deps.error(
'No OpenKnowledge desktop app found and no UI is running. ' +
'Install OK Desktop, or start a UI with `ok ui`, then retry.',
);
return 1;
return noTargetError(deps);
}

export function openCommand(): Command {
return new Command('open')
.description('Open a doc in the OK Desktop app (folders open in the browser)')
.description(
'Open a doc, folder, or skill in the OK Desktop app (falls back to the browser UI). ' +
'Docs and folders are auto-detected — no flag needed.',
)
.argument(
'<doc>',
'Extension-less doc path (e.g. specs/foo/SPEC), or a folder path with --folder',
'<name>',
'Doc path (specs/foo/SPEC), folder path (specs/foo or specs/foo/), or a skill name with --skill',
)
.option('--skill', 'Open <name> as a skill in the skill editor')
.option(
'--scope <scope>',
`Skill scope when --skill is set: ${MANAGED_ARTIFACT_SCOPES.join(' | ')}`,
'project',
)
.option('--folder', 'Treat <doc> as a folder and open the folder route in the browser')
.option('--project <dir>', 'Project root (defaults to the current directory)')
.action((name: string, options: OpenOptions) => {
process.exitCode = runOpen(name, options, createRealOpenDeps());
Expand Down
34 changes: 33 additions & 1 deletion packages/cli/src/commands/single-file-dispatch.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { describe, expect, test } from 'bun:test';
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
decideSingleFileTarget,
hasMarkdownExtension,
isFileishTarget,
scanRootArgv,
} from './single-file-dispatch.ts';

Expand Down Expand Up @@ -93,3 +97,31 @@ describe('hasMarkdownExtension', () => {
expect(hasMarkdownExtension('a.md.txt')).toBe(false);
});
});

describe('isFileishTarget (fs-backed predicate)', () => {
let dir: string;
beforeAll(() => {
dir = mkdtempSync(join(tmpdir(), 'ok-fileish-'));
writeFileSync(join(dir, 'note.md'), '# note');
writeFileSync(join(dir, 'data.json'), '{}');
mkdirSync(join(dir, 'a-folder'));
});
afterAll(() => rmSync(dir, { recursive: true, force: true }));

test('a markdown-extension token is fileish (even if it does not exist)', () => {
expect(isFileishTarget(join(dir, 'missing.md'), 'missing.md')).toBe(true);
});

test('an existing regular file is fileish', () => {
expect(isFileishTarget(join(dir, 'data.json'), 'data.json')).toBe(true);
expect(isFileishTarget(join(dir, 'note.md'), 'note.md')).toBe(true);
});

test('an existing DIRECTORY is NOT fileish — so `ok open <folder>` falls through to the open command', () => {
expect(isFileishTarget(join(dir, 'a-folder'), 'a-folder')).toBe(false);
});

test('a non-existent non-markdown token is not fileish', () => {
expect(isFileishTarget(join(dir, 'nope'), 'nope')).toBe(false);
});
});
Loading