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: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,11 @@ __pycache__/
*.pyo
# Generated platform companion build metadata (do not commit)
packages/sdk/langs/python/platforms/**/*.egg-info/
# Generated python packaging metadata (do not commit)
packages/sdk/langs/python/**/*.egg-info/

# Local runtime state for collaboration examples
examples/collaboration/fastapi/.superdoc-state/

# Generated artifacts — run `pnpm run generate:all` to produce
packages/document-api/generated/
Expand Down
59 changes: 59 additions & 0 deletions apps/cli/src/__tests__/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1860,6 +1860,39 @@ describe('superdoc CLI', () => {
expect(unsupportedTokenEnvelope.error.code).toBe('VALIDATION_ERROR');
});

test('open rejects primitive --collaboration-json with --on-missing', async () => {
const result = await runCli(['open', SAMPLE_DOC, '--collaboration-json', '"oops"', '--on-missing', 'blank']);
expect(result.code).toBe(1);
const envelope = parseJsonOutput<ErrorEnvelope>(result);
expect(envelope.error.code).toBe('VALIDATION_ERROR');
});

test('open rejects array --collaboration-json', async () => {
const result = await runCli(['open', SAMPLE_DOC, '--collaboration-json', '[1, 2]']);
expect(result.code).toBe(1);
const envelope = parseJsonOutput<ErrorEnvelope>(result);
expect(envelope.error.code).toBe('VALIDATION_ERROR');
});

test('open with collaboration does not require a doc path', async () => {
const result = await runCli([
'open',
'--collaboration-json',
JSON.stringify({
providerType: 'hocuspocus',
url: 'ws://127.0.0.1:9',
syncTimeoutMs: 1,
}),
'--session',
'collab-no-doc',
]);

expect(result.code).toBe(1);
const envelope = parseJsonOutput<ErrorEnvelope>(result);
// Verify we no longer fail argument validation for a missing document path.
expect(envelope.error.code).not.toBe('MISSING_REQUIRED');
});

test('open with --session is idempotent for the same session id', async () => {
const firstOpen = await runCli(['open', SAMPLE_DOC, '--session', 'draft-a']);
expect(firstOpen.code).toBe(0);
Expand Down Expand Up @@ -2118,4 +2151,30 @@ describe('superdoc CLI', () => {
const closeResult = await runCli(['close', '--discard']);
expect(closeResult.code).toBe(0);
});

test('open with --user-name and --user-email succeeds', async () => {
const openResult = await runCli([
'open',
SAMPLE_DOC,
'--user-name',
'Review Bot',
'--user-email',
'bot@example.com',
]);
expect(openResult.code).toBe(0);

const envelope = parseJsonOutput<SuccessEnvelope<{ active: boolean }>>(openResult);
expect(envelope.data.active).toBe(true);

const closeResult = await runCli(['close', '--discard']);
expect(closeResult.code).toBe(0);
});

test('open with --user-name only (no --user-email) succeeds', async () => {
const openResult = await runCli(['open', SAMPLE_DOC, '--user-name', 'Bot']);
expect(openResult.code).toBe(0);

const closeResult = await runCli(['close', '--discard']);
expect(closeResult.code).toBe(0);
});
});
105 changes: 105 additions & 0 deletions apps/cli/src/__tests__/lib/context.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { describe, expect, test } from 'bun:test';
import { normalizeContextMetadata, type ContextMetadata } from '../../lib/context';

function makeMetadata(overrides: Partial<ContextMetadata> = {}): ContextMetadata {
return {
contextId: 'test-session',
projectRoot: '/tmp/test',
source: 'path',
sourcePath: '/tmp/test/doc.docx',
workingDocPath: '/tmp/test/working.docx',
dirty: false,
revision: 0,
sessionType: 'local',
openedAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
...overrides,
};
}

describe('normalizeContextMetadata', () => {
describe('user normalization', () => {
test('preserves valid user', () => {
const metadata = makeMetadata({ user: { name: 'Bot', email: 'bot@co.com' } });
const result = normalizeContextMetadata(metadata);
expect(result.user).toEqual({ name: 'Bot', email: 'bot@co.com' });
});

test('strips non-object user', () => {
const metadata = makeMetadata({ user: 42 as any });
const result = normalizeContextMetadata(metadata);
expect(result.user).toBeUndefined();
});

test('strips user with non-string name', () => {
const metadata = makeMetadata({ user: { name: 123, email: 'a@b.com' } as any });
const result = normalizeContextMetadata(metadata);
expect(result.user).toBeUndefined();
});

test('strips user with empty name', () => {
const metadata = makeMetadata({ user: { name: '', email: 'a@b.com' } });
const result = normalizeContextMetadata(metadata);
expect(result.user).toBeUndefined();
});

test('strips user with non-string email', () => {
const metadata = makeMetadata({ user: { name: 'Bot', email: 123 } as any });
const result = normalizeContextMetadata(metadata);
expect(result.user).toBeUndefined();
});

test('preserves user with empty email', () => {
const metadata = makeMetadata({ user: { name: 'Bot', email: '' } });
const result = normalizeContextMetadata(metadata);
expect(result.user).toEqual({ name: 'Bot', email: '' });
});

test('strips array user', () => {
const metadata = makeMetadata({ user: ['Bot'] as any });
const result = normalizeContextMetadata(metadata);
expect(result.user).toBeUndefined();
});

test('strips string user', () => {
const metadata = makeMetadata({ user: 'Bot' as any });
const result = normalizeContextMetadata(metadata);
expect(result.user).toBeUndefined();
});

test('preserves undefined user', () => {
const metadata = makeMetadata();
const result = normalizeContextMetadata(metadata);
expect(result.user).toBeUndefined();
});
});

describe('session type normalization', () => {
test('normalizes unknown session type to local', () => {
const metadata = makeMetadata({ sessionType: 'unknown' as any });
const result = normalizeContextMetadata(metadata);
expect(result.sessionType).toBe('local');
});

test('preserves collab session type with valid collaboration', () => {
const metadata = makeMetadata({
sessionType: 'collab',
collaboration: {
providerType: 'hocuspocus',
url: 'ws://localhost:4000',
documentId: 'test-doc',
},
});
const result = normalizeContextMetadata(metadata);
expect(result.sessionType).toBe('collab');
expect(result.collaboration).toBeDefined();
});

test('falls back to local when collab profile is missing', () => {
const metadata = makeMetadata({ sessionType: 'collab' });
const result = normalizeContextMetadata(metadata);
expect(result.sessionType).toBe('local');
expect(result.collaboration).toBeUndefined();
});
});
});
16 changes: 15 additions & 1 deletion apps/cli/src/__tests__/lib/operation-runtime-metadata.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, test } from 'bun:test';
import { CLI_OPERATION_METADATA, type CliOperationId } from '../../cli';
import { CLI_OPERATION_METADATA, CLI_OPERATION_OPTION_SPECS, type CliOperationId } from '../../cli';
import { getOperationRuntimeMetadata } from '../../lib/operation-runtime-metadata';

describe('operation runtime metadata', () => {
Expand Down Expand Up @@ -42,4 +42,18 @@ describe('operation runtime metadata', () => {
expect(describeCommand.context.supportsStateless).toBe(true);
expect(describeCommand.context.supportsSession).toBe(false);
});

test('doc.open metadata includes userName and userEmail params', () => {
const openMeta = CLI_OPERATION_METADATA['doc.open'];
const paramNames = openMeta.params.map((p) => p.name);
expect(paramNames).toContain('userName');
expect(paramNames).toContain('userEmail');
});

test('doc.open option specs include user-name and user-email flags', () => {
const openOptions = CLI_OPERATION_OPTION_SPECS['doc.open'];
const optionNames = openOptions.map((o) => o.name);
expect(optionNames).toContain('user-name');
expect(optionNames).toContain('user-email');
});
});
8 changes: 8 additions & 0 deletions apps/cli/src/cli/cli-only-operation-definitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,14 @@ export const CLI_ONLY_OPERATION_DEFINITIONS: Record<CliOnlyOperation, CliOnlyOpe
url: { type: 'string' },
},
},
bootstrap: {
type: 'object',
properties: {
roomState: { type: 'string' },
bootstrapApplied: { type: 'boolean' },
bootstrapSource: { type: 'string' },
},
},
},
required: ['contextId', 'sessionType'],
},
Expand Down
16 changes: 16 additions & 0 deletions apps/cli/src/cli/operation-params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,18 @@ const EXPECTED_REVISION_PARAM: CliOperationParamSpec = {
type: 'number',
agentVisible: false,
};
const USER_NAME_PARAM: CliOperationParamSpec = {
name: 'userName',
kind: 'flag',
flag: 'user-name',
type: 'string',
};
const USER_EMAIL_PARAM: CliOperationParamSpec = {
name: 'userEmail',
kind: 'flag',
flag: 'user-email',
type: 'string',
};

// ---------------------------------------------------------------------------
// Schema → param derivation
Expand Down Expand Up @@ -422,6 +434,10 @@ const CLI_ONLY_METADATA: Record<CliOnlyOperationId, CliOperationMetadata> = {
{ name: 'collabUrl', kind: 'flag', flag: 'collab-url', type: 'string' },
{ name: 'contentOverride', kind: 'flag', flag: 'content-override', type: 'string' },
{ name: 'overrideType', kind: 'flag', flag: 'override-type', type: 'string' },
{ name: 'onMissing', kind: 'flag', flag: 'on-missing', type: 'string' },
{ name: 'bootstrapSettlingMs', kind: 'flag', flag: 'bootstrap-settling-ms', type: 'number' },
USER_NAME_PARAM,
USER_EMAIL_PARAM,
],
constraints: null,
},
Expand Down
46 changes: 38 additions & 8 deletions apps/cli/src/commands/open.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { getBooleanOption, getStringOption, resolveDocArg, resolveJsonInput } from '../lib/args';
import { getBooleanOption, getNumberOption, getStringOption, resolveDocArg, resolveJsonInput } from '../lib/args';
import { parseCollaborationInput, resolveCollaborationProfile } from '../lib/collaboration';
import {
getProjectRoot,
Expand All @@ -17,6 +17,7 @@ import { generateSessionId } from '../lib/session';
import type { CommandContext, CommandExecution } from '../lib/types';

const VALID_OVERRIDE_TYPES = new Set(['markdown', 'html', 'text']);
const VALID_ON_MISSING = new Set(['seedFromDoc', 'blank', 'error']);

export async function runOpen(tokens: string[], context: CommandContext): Promise<CommandExecution> {
const { parsed, help } = parseOperationArgs('doc.open', tokens, {
Expand Down Expand Up @@ -51,6 +52,10 @@ export async function runOpen(tokens: string[], context: CommandContext): Promis
const collabDocumentId = getStringOption(parsed, 'collab-document-id');
const contentOverride = getStringOption(parsed, 'content-override');
const overrideType = getStringOption(parsed, 'override-type');
const onMissing = getStringOption(parsed, 'on-missing');
const bootstrapSettlingMs = getNumberOption(parsed, 'bootstrap-settling-ms');
const userName = getStringOption(parsed, 'user-name');
const userEmail = getStringOption(parsed, 'user-email');

// Validate contentOverride / overrideType co-requirement.
// Use != null checks so that intentional empty-string overrides are honored.
Expand All @@ -67,6 +72,13 @@ export async function runOpen(tokens: string[], context: CommandContext): Promis
);
}

if (onMissing != null && !VALID_ON_MISSING.has(onMissing)) {
throw new CliError(
'INVALID_ARGUMENT',
`open: --on-missing must be one of: seedFromDoc, blank, error. Got "${onMissing}".`,
);
}

if (collaborationPayload != null && (collabUrl || collabDocumentId)) {
throw new CliError(
'INVALID_ARGUMENT',
Expand All @@ -84,12 +96,21 @@ export async function runOpen(tokens: string[], context: CommandContext): Promis

let collaborationInput;
if (collaborationPayload != null) {
collaborationInput = parseCollaborationInput(collaborationPayload);
if (typeof collaborationPayload !== 'object' || Array.isArray(collaborationPayload)) {
throw new CliError('VALIDATION_ERROR', 'open: --collaboration-json must be a JSON object.');
}
const payload = collaborationPayload as Record<string, unknown>;
if (onMissing != null && !('onMissing' in payload)) payload.onMissing = onMissing;
if (bootstrapSettlingMs != null && !('bootstrapSettlingMs' in payload))
payload.bootstrapSettlingMs = bootstrapSettlingMs;
collaborationInput = parseCollaborationInput(payload);
} else if (collabUrl) {
collaborationInput = parseCollaborationInput({
providerType: 'hocuspocus',
url: collabUrl,
documentId: collabDocumentId,
...(onMissing != null ? { onMissing } : {}),
...(bootstrapSettlingMs != null ? { bootstrapSettlingMs } : {}),
});
} else if (collabDocumentId) {
throw new CliError('MISSING_REQUIRED', 'open: --collab-document-id requires --collab-url.');
Expand All @@ -98,6 +119,16 @@ export async function runOpen(tokens: string[], context: CommandContext): Promis
const collaboration = collaborationInput ? resolveCollaborationProfile(collaborationInput, sessionId) : undefined;
const sessionType = collaboration ? 'collab' : 'local';

if (!collaboration && (onMissing != null || bootstrapSettlingMs != null)) {
throw new CliError(
'INVALID_ARGUMENT',
'open: --on-missing and --bootstrap-settling-ms require collaboration mode (--collaboration-json or --collab-url).',
);
}

// Build user identity when either flag is provided.
const user = userName != null || userEmail != null ? { name: userName ?? 'CLI', email: userEmail ?? '' } : undefined;

// Build editor open options from override params
const editorOpenOptions: Record<string, string> = {};
if (contentOverride != null && overrideType) {
Expand Down Expand Up @@ -141,13 +172,10 @@ export async function runOpen(tokens: string[], context: CommandContext): Promis
);
}

if (collaboration && doc == null) {
throw new CliError('MISSING_REQUIRED', 'open: a document path is required when using collaboration.');
}

const opened = collaboration
? await openCollaborativeDocument(doc!, context.io, collaboration)
: await openDocument(doc, context.io, { editorOpenOptions });
? await openCollaborativeDocument(doc, context.io, collaboration, { user })
: await openDocument(doc, context.io, { editorOpenOptions, user });
const bootstrap = 'bootstrap' in opened ? opened.bootstrap : undefined;
let adoptedToHostPool = false;
try {
const output = await exportToPath(opened.editor, paths.workingDocPath, true);
Expand All @@ -163,6 +191,7 @@ export async function runOpen(tokens: string[], context: CommandContext): Promis
sourceSnapshot,
sessionType,
collaboration,
user,
});

await writeContextMetadata(paths, metadata);
Expand All @@ -187,6 +216,7 @@ export async function runOpen(tokens: string[], context: CommandContext): Promis
dirty: metadata.dirty,
sessionType: metadata.sessionType,
collaboration: metadata.collaboration,
bootstrap,
openedAt: metadata.openedAt,
updatedAt: metadata.updatedAt,
},
Expand Down
Loading
Loading