diff --git a/.changeset/committed-autosync-default.md b/.changeset/committed-autosync-default.md new file mode 100644 index 00000000..1ec46a05 --- /dev/null +++ b/.changeset/committed-autosync-default.md @@ -0,0 +1,7 @@ +--- +"@inkeep/open-knowledge": patch +--- + +Add a committed, project-level auto-sync default (`autoSync.default`). A maintainer can now ship a project that opens quietly for collaborators: set **Settings → Sync → Default for everyone** to "Off by default" (or "On by default") and the choice is committed to `.ok/config.yml` and travels with the repo via git. New clones then skip the "enable auto-sync" onboarding prompt and open with sync in the chosen state. + +`autoSync.default` is `true | false | null` (null/absent = ask, the previous behavior) and seeds each machine's `autoSync.enabled` on first open. It is a soft default — anyone can still change auto-sync for their own machine in Settings, which overrides the committed default for that machine only. The per-machine `autoSync.enabled` setting stays gitignored and per-machine. diff --git a/docs/content/features/github-sync.mdx b/docs/content/features/github-sync.mdx index ffcc6e53..f09a5558 100644 --- a/docs/content/features/github-sync.mdx +++ b/docs/content/features/github-sync.mdx @@ -64,6 +64,8 @@ While sync is active, Open Knowledge: Pulls can overwrite uncommitted local file changes, so commit or discard work-in-progress before you enable sync. +If you want to set a default auto-sync setting for future collaborators, go to the project settings page and navigate to the **Sync** -> **Shared default** section. + ## Authentication If you are logged in with the GitHub CLI, Open Knowledge will use those credentials for GitHub operations. If not, you can complete device authentication to generate an OAuth token. Open Knowledge stores this token in the keychain and reuses it for syncing, cloning, and publishing. diff --git a/packages/app/src/components/EditorPane.dom.test.tsx b/packages/app/src/components/EditorPane.dom.test.tsx index 31b3227d..b44d29cb 100644 --- a/packages/app/src/components/EditorPane.dom.test.tsx +++ b/packages/app/src/components/EditorPane.dom.test.tsx @@ -14,7 +14,9 @@ mock.module('@lingui/react/macro', () => ({ let hasRemote = false; let projectLocalSynced = false; +let projectSynced = false; let projectLocalConfig: { autoSync?: { enabled?: boolean | null } } | null = null; +let projectConfig: { autoSync?: { default?: boolean | null } } | null = null; mock.module('@/hooks/use-git-sync-status', () => ({ useGitSyncStatus: () => ({ @@ -24,7 +26,12 @@ mock.module('@/hooks/use-git-sync-status', () => ({ })); mock.module('@/lib/config-provider', () => ({ - useConfigContext: () => ({ projectLocalConfig, projectLocalSynced }), + useConfigContext: () => ({ + projectConfig, + projectLocalConfig, + projectLocalSynced, + projectSynced, + }), })); mock.module('@/lib/use-workspace', () => ({ @@ -102,7 +109,9 @@ describe('EditorPane auto-sync onboarding gate', () => { cleanup(); hasRemote = false; projectLocalSynced = false; + projectSynced = false; projectLocalConfig = null; + projectConfig = null; }); test('exports the EditorPane component', async () => { @@ -110,10 +119,12 @@ describe('EditorPane auto-sync onboarding gate', () => { expect(typeof mod.EditorPane).toBe('function'); }); - test('opens only when remote exists, project-local config is synced, and autoSync.enabled is null', async () => { + test('opens when remote exists, both configs synced, enabled is null, and no committed default', async () => { hasRemote = true; + projectSynced = true; projectLocalSynced = true; projectLocalConfig = { autoSync: { enabled: null } }; + projectConfig = { autoSync: { default: null } }; await renderEditorPane(); @@ -121,16 +132,77 @@ describe('EditorPane auto-sync onboarding gate', () => { }); test.each([ - ['no remote', false, true, { autoSync: { enabled: null } }], - ['project-local config not synced', true, false, { autoSync: { enabled: null } }], - ['project-local config missing', true, true, null], - ['enabled true already answered', true, true, { autoSync: { enabled: true } }], - ['enabled false already answered', true, true, { autoSync: { enabled: false } }], - ['enabled undefined is not the unanswered sentinel', true, true, { autoSync: {} }], - ] as const)('stays closed when %s', async (_label, nextHasRemote, nextSynced, nextProjectLocalConfig) => { + [ + 'no remote', + false, + true, + true, + { autoSync: { enabled: null } }, + { autoSync: { default: null } }, + ], + [ + 'committed config not synced', + true, + false, + true, + { autoSync: { enabled: null } }, + { autoSync: { default: null } }, + ], + [ + 'project-local config not synced', + true, + true, + false, + { autoSync: { enabled: null } }, + { autoSync: { default: null } }, + ], + ['project-local config missing', true, true, true, null, { autoSync: { default: null } }], + [ + 'enabled true already answered', + true, + true, + true, + { autoSync: { enabled: true } }, + { autoSync: { default: null } }, + ], + [ + 'enabled false already answered', + true, + true, + true, + { autoSync: { enabled: false } }, + { autoSync: { default: null } }, + ], + [ + 'enabled undefined is not the unanswered sentinel', + true, + true, + true, + { autoSync: {} }, + { autoSync: { default: null } }, + ], + [ + 'committed default off suppresses the prompt', + true, + true, + true, + { autoSync: { enabled: null } }, + { autoSync: { default: false } }, + ], + [ + 'committed default on suppresses the prompt', + true, + true, + true, + { autoSync: { enabled: null } }, + { autoSync: { default: true } }, + ], + ] as const)('stays closed when %s', async (_label, nextHasRemote, nextProjectSynced, nextSynced, nextProjectLocalConfig, nextProjectConfig) => { hasRemote = nextHasRemote; + projectSynced = nextProjectSynced; projectLocalSynced = nextSynced; projectLocalConfig = nextProjectLocalConfig; + projectConfig = nextProjectConfig; await renderEditorPane(); @@ -139,8 +211,10 @@ describe('EditorPane auto-sync onboarding gate', () => { test('resolved onboarding dismisses the dialog in the same render path', async () => { hasRemote = true; + projectSynced = true; projectLocalSynced = true; projectLocalConfig = { autoSync: { enabled: null } }; + projectConfig = { autoSync: { default: null } }; await renderEditorPane(); const dialog = screen.getByTestId('auto-sync-onboarding'); diff --git a/packages/app/src/components/EditorPane.tsx b/packages/app/src/components/EditorPane.tsx index ea679db8..acd592b5 100644 --- a/packages/app/src/components/EditorPane.tsx +++ b/packages/app/src/components/EditorPane.tsx @@ -54,7 +54,8 @@ export function EditorPane({ onOpenSearch }: EditorPaneProps = {}) { const launchNonceRef = useRef(0); const syncStatus = useGitSyncStatus(); - const { projectLocalConfig, projectLocalSynced } = useConfigContext(); + const { projectConfig, projectLocalConfig, projectLocalSynced, projectSynced } = + useConfigContext(); const workspace = useWorkspace(); const { activeDocName } = useDocumentContext(); @@ -63,7 +64,9 @@ export function EditorPane({ onOpenSearch }: EditorPaneProps = {}) { autoSyncOnboardingDismissed, hasRemote: syncStatus?.hasRemote, projectLocalSynced, + projectSynced, projectLocalConfig, + projectConfig, pushPermissionCheckStatus: syncStatus?.pushPermission?.checkStatus, }); diff --git a/packages/app/src/components/auto-sync-onboarding-gate.test.ts b/packages/app/src/components/auto-sync-onboarding-gate.test.ts new file mode 100644 index 00000000..e4fd4b69 --- /dev/null +++ b/packages/app/src/components/auto-sync-onboarding-gate.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, test } from 'bun:test'; +import { + type AutoSyncOnboardingGateInputs, + shouldShowAutoSyncOnboarding, +} from './auto-sync-onboarding-gate.ts'; + +const SHOWING: AutoSyncOnboardingGateInputs = { + autoSyncOnboardingDismissed: false, + hasRemote: true, + projectLocalSynced: true, + projectSynced: true, + projectLocalConfig: { autoSync: { enabled: null } }, + projectConfig: { autoSync: { default: null } }, + pushPermissionCheckStatus: 'allowed', +}; + +describe('shouldShowAutoSyncOnboarding', () => { + test('shows when every condition is aligned (unanswered machine, no committed default)', () => { + expect(shouldShowAutoSyncOnboarding(SHOWING)).toBe(true); + }); + + test('hidden once dismissed this session', () => { + expect(shouldShowAutoSyncOnboarding({ ...SHOWING, autoSyncOnboardingDismissed: true })).toBe( + false, + ); + }); + + test('hidden without a git remote', () => { + expect(shouldShowAutoSyncOnboarding({ ...SHOWING, hasRemote: false })).toBe(false); + expect(shouldShowAutoSyncOnboarding({ ...SHOWING, hasRemote: undefined })).toBe(false); + }); + + test('hidden until the project-local binding has synced (flash-free)', () => { + expect(shouldShowAutoSyncOnboarding({ ...SHOWING, projectLocalSynced: false })).toBe(false); + expect(shouldShowAutoSyncOnboarding({ ...SHOWING, projectLocalSynced: undefined })).toBe(false); + }); + + test('hidden until the committed project binding has synced (flash-free)', () => { + expect(shouldShowAutoSyncOnboarding({ ...SHOWING, projectSynced: false })).toBe(false); + expect(shouldShowAutoSyncOnboarding({ ...SHOWING, projectSynced: undefined })).toBe(false); + }); + + test('hidden until project-local config hydrates', () => { + expect(shouldShowAutoSyncOnboarding({ ...SHOWING, projectLocalConfig: null })).toBe(false); + }); + + test('hidden once this machine has answered (enabled true or false)', () => { + expect( + shouldShowAutoSyncOnboarding({ + ...SHOWING, + projectLocalConfig: { autoSync: { enabled: true } }, + }), + ).toBe(false); + expect( + shouldShowAutoSyncOnboarding({ + ...SHOWING, + projectLocalConfig: { autoSync: { enabled: false } }, + }), + ).toBe(false); + }); + + test('suppressed when the maintainer committed autoSync.default: false', () => { + expect( + shouldShowAutoSyncOnboarding({ + ...SHOWING, + projectConfig: { autoSync: { default: false } }, + }), + ).toBe(false); + }); + + test('suppressed when the maintainer committed autoSync.default: true', () => { + expect( + shouldShowAutoSyncOnboarding({ + ...SHOWING, + projectConfig: { autoSync: { default: true } }, + }), + ).toBe(false); + }); + + test('still asks when committed config is absent or default is null/absent', () => { + expect(shouldShowAutoSyncOnboarding({ ...SHOWING, projectConfig: null })).toBe(true); + expect(shouldShowAutoSyncOnboarding({ ...SHOWING, projectConfig: { autoSync: {} } })).toBe( + true, + ); + expect(shouldShowAutoSyncOnboarding({ ...SHOWING, projectConfig: {} })).toBe(true); + }); + + test('hidden when the push-permission probe denied or is still pending', () => { + expect(shouldShowAutoSyncOnboarding({ ...SHOWING, pushPermissionCheckStatus: 'denied' })).toBe( + false, + ); + expect(shouldShowAutoSyncOnboarding({ ...SHOWING, pushPermissionCheckStatus: undefined })).toBe( + false, + ); + }); + + test('shows on probe unknown (graceful degradation)', () => { + expect(shouldShowAutoSyncOnboarding({ ...SHOWING, pushPermissionCheckStatus: 'unknown' })).toBe( + true, + ); + }); +}); diff --git a/packages/app/src/components/auto-sync-onboarding-gate.ts b/packages/app/src/components/auto-sync-onboarding-gate.ts index 3bc60338..bfa34efe 100644 --- a/packages/app/src/components/auto-sync-onboarding-gate.ts +++ b/packages/app/src/components/auto-sync-onboarding-gate.ts @@ -2,7 +2,9 @@ export interface AutoSyncOnboardingGateInputs { autoSyncOnboardingDismissed: boolean; hasRemote: boolean | undefined; projectLocalSynced: boolean | undefined; + projectSynced: boolean | undefined; projectLocalConfig: { autoSync?: { enabled: boolean | null } | null } | null; + projectConfig: { autoSync?: { default?: boolean | null } | null } | null; pushPermissionCheckStatus: 'allowed' | 'denied' | 'unknown' | undefined; } @@ -11,8 +13,10 @@ export function shouldShowAutoSyncOnboarding(inputs: AutoSyncOnboardingGateInput !inputs.autoSyncOnboardingDismissed && inputs.hasRemote === true && inputs.projectLocalSynced === true && + inputs.projectSynced === true && inputs.projectLocalConfig !== null && inputs.projectLocalConfig.autoSync?.enabled === null && + (inputs.projectConfig?.autoSync?.default ?? null) === null && (inputs.pushPermissionCheckStatus === 'allowed' || inputs.pushPermissionCheckStatus === 'unknown') ); diff --git a/packages/app/src/components/settings/SettingsDialogBody.sections.dom.test.tsx b/packages/app/src/components/settings/SettingsDialogBody.sections.dom.test.tsx index f628f281..6150c172 100644 --- a/packages/app/src/components/settings/SettingsDialogBody.sections.dom.test.tsx +++ b/packages/app/src/components/settings/SettingsDialogBody.sections.dom.test.tsx @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, mock, test } from 'bun:test'; import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; -import { type ReactNode, useState } from 'react'; +import { createContext, type ReactNode, use, useState } from 'react'; import { renderLinguiTemplate } from '@/test-utils/lingui-mock'; type SyncStatus = { @@ -21,7 +21,12 @@ let projectLocalConfig: { autoSync?: { enabled?: boolean } } | null = { autoSync: { enabled: true }, }; let projectLocalSynced = true; +let projectConfig: { autoSync?: { default?: boolean | null } } | null = { + autoSync: { default: null }, +}; +let projectSynced = true; let syncWriterCalls: boolean[] = []; +let syncDefaultWriterCalls: Array = []; let okignoreProps: Array<{ binding: unknown; synced: boolean }> = []; let installDialogProps: Array<{ open: boolean; @@ -109,13 +114,43 @@ mock.module('@/components/ui/input', () => ({ Input: (props: React.InputHTMLAttributes) => , })); +const ToggleGroupHandlerCtx = createContext<((value: string) => void) | undefined>(undefined); mock.module('@/components/ui/toggle-group', () => ({ - ToggleGroup: ({ children }: { children?: ReactNode }) =>
{children}
, - ToggleGroupItem: ({ children, ...props }: { children?: ReactNode; [key: string]: unknown }) => ( - + ToggleGroup: ({ + children, + value, + onValueChange, + disabled, + ...props + }: { + children?: ReactNode; + value?: string; + onValueChange?: (value: string) => void; + disabled?: boolean; + [key: string]: unknown; + }) => ( + +
+ {children} +
+
), + ToggleGroupItem: ({ + children, + value, + ...props + }: { + children?: ReactNode; + value?: string; + [key: string]: unknown; + }) => { + const onValueChange = use(ToggleGroupHandlerCtx); + return ( + + ); + }, })); mock.module('@/components/ui/tooltip', () => ({ @@ -167,8 +202,10 @@ mock.module('@/hooks/use-git-sync-status', () => ({ mock.module('@/lib/config-provider', () => ({ useConfigContext: () => ({ + projectConfig, projectLocalConfig, projectLocalSynced, + projectSynced, }), })); @@ -179,6 +216,10 @@ mock.module('@/hooks/use-enable-sync-with-confirm', () => ({ return true; }, }), + useSyncDefaultWriter: () => (next: boolean | null) => { + syncDefaultWriterCalls.push(next); + return { ok: true }; + }, useEnableSyncWithConfirm: (writer: { write: (enabled: boolean) => boolean }) => { const [confirmOpen, setConfirmOpen] = useState(false); return { @@ -246,7 +287,10 @@ describe('SettingsDialogBody section runtime dispatch', () => { syncStatus = null; projectLocalConfig = { autoSync: { enabled: true } }; projectLocalSynced = true; + projectConfig = { autoSync: { default: null } }; + projectSynced = true; syncWriterCalls = []; + syncDefaultWriterCalls = []; okignoreProps = []; installDialogProps = []; publishDialogProps = []; @@ -326,6 +370,52 @@ describe('SettingsDialogBody section runtime dispatch', () => { ); }); + test('committed default control reflects autoSync.default and writes the chosen seed', async () => { + syncStatus = { + state: 'enabled', + hasRemote: true, + syncEnabled: false, + remote: { + label: 'inkeep/open-knowledge', + webUrl: 'https://github.com/inkeep/open-knowledge', + }, + }; + projectConfig = { autoSync: { default: false } }; + projectSynced = true; + + await renderBody({ activeId: 'sync' }); + + expect(screen.getByTestId('settings-sync-default-toggle').getAttribute('data-value')).toBe( + 'off', + ); + + fireEvent.click(screen.getByTestId('settings-sync-default-on')); + expect(syncDefaultWriterCalls).toEqual([true]); + + fireEvent.click(screen.getByTestId('settings-sync-default-ask')); + expect(syncDefaultWriterCalls).toEqual([true, null]); + }); + + test('committed default control is disabled until the committed config has synced', async () => { + syncStatus = { + state: 'enabled', + hasRemote: true, + syncEnabled: false, + remote: { + label: 'inkeep/open-knowledge', + webUrl: 'https://github.com/inkeep/open-knowledge', + }, + }; + projectConfig = { autoSync: { default: null } }; + projectSynced = false; + + await renderBody({ activeId: 'sync' }); + + expect(screen.getByTestId('settings-sync-default-toggle').getAttribute('data-disabled')).toBe( + 'true', + ); + }); + test('sync section disables the toggle with denied-specific accessible copy when push permission is denied', async () => { syncStatus = { state: 'idle', diff --git a/packages/app/src/components/settings/SettingsDialogBody.tsx b/packages/app/src/components/settings/SettingsDialogBody.tsx index eed9e307..9329f1d9 100644 --- a/packages/app/src/components/settings/SettingsDialogBody.tsx +++ b/packages/app/src/components/settings/SettingsDialogBody.tsx @@ -51,6 +51,7 @@ import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { useEnableSyncWithConfirm, + useSyncDefaultWriter, useSyncEnabledWriter, } from '@/hooks/use-enable-sync-with-confirm'; import { useGitSyncStatus } from '@/hooks/use-git-sync-status'; @@ -109,6 +110,9 @@ const FIELDS_USER_PREFERENCES: FieldDef[] = [ }, ]; +const COMMITTED_DEFAULT_SELECTED_CLASS = + 'data-[state=on]:border-primary data-[state=on]:bg-primary data-[state=on]:text-primary-foreground data-[state=on]:hover:bg-primary/90'; + interface SettingsDialogBodyProps { activeId: string; userBinding: ConfigBinding | null; @@ -410,8 +414,10 @@ function SchemaSection({ function SyncSection() { const { t } = useLingui(); const status = useGitSyncStatus(); - const { projectLocalConfig, projectLocalSynced } = useConfigContext(); + const { projectConfig, projectLocalConfig, projectLocalSynced, projectSynced } = + useConfigContext(); const writer = useSyncEnabledWriter(); + const defaultWriter = useSyncDefaultWriter(); const { confirmOpen, setConfirmOpen, onToggleRequest, onConfirm } = useEnableSyncWithConfirm(writer); const [publishOpen, setPublishOpen] = useState(false); @@ -486,6 +492,23 @@ function SyncSection() { const sectionMessage = isPushDenied || !status?.pausedReason ? null : formatPausedReason(status.pausedReason); + const committedDefault = projectConfig?.autoSync?.default ?? null; + const committedDefaultValue = + committedDefault === true ? 'on' : committedDefault === false ? 'off' : 'ask'; + function onCommittedDefaultChange(next: string) { + if (next !== 'ask' && next !== 'on' && next !== 'off') return; + if (defaultWriter === null) { + toast.error(t`Sync settings not yet loaded — try again in a moment`); + return; + } + const value = next === 'on' ? true : next === 'off' ? false : null; + const result = defaultWriter(value); + if (!result.ok) { + const detail = result.error; + toast.error(t`Failed to update the project sync default — ${detail}`); + } + } + return (
@@ -583,6 +606,51 @@ function SyncSection() {
)} +
+
+
+ Shared default +
+

+ + Set the auto-sync default for users opening this project for the first time. This + setting is committed to your repository. + +

+
+ + + None + + + On + + + Off + + +
({ let projectLocalBinding: null | { patch: (patch: unknown) => { ok: true } | { ok: false; error: unknown }; } = null; +let projectBinding: null | { + patch: (patch: unknown) => { ok: true } | { ok: false; error: unknown }; +} = null; mock.module('@/lib/config-provider', () => ({ - useConfigContext: () => ({ projectLocalBinding }), + useConfigContext: () => ({ projectBinding, projectLocalBinding }), })); type Writer = ((enabled: boolean) => { ok: true } | { ok: false; error: string }) | null; @@ -52,6 +55,15 @@ function WriterProbe({ children: _children }: { children?: ReactNode }) { return
{String(latestWriter !== null)}
; } +type DefaultWriter = ((next: boolean | null) => { ok: true } | { ok: false; error: string }) | null; +let latestDefaultWriter: DefaultWriter | undefined; + +function DefaultWriterProbe() { + if (!hooks) throw new Error('hooks not loaded'); + latestDefaultWriter = hooks.useSyncDefaultWriter(); + return
{String(latestDefaultWriter !== null)}
; +} + describe('useEnableSyncWithConfirm runtime behavior', () => { let consoleErrorSpy: ReturnType; @@ -59,15 +71,18 @@ describe('useEnableSyncWithConfirm runtime behavior', () => { cleanup(); latestConfirmState = null; latestWriter = undefined; + latestDefaultWriter = undefined; projectLocalBinding = null; + projectBinding = null; toastErrors.length = 0; consoleErrorSpy?.mockRestore(); }); - test('exports the hook and project-local writer adapter', async () => { + test('exports the hook and both writer adapters', async () => { const mod = await loadHooks(); expect(typeof mod.useEnableSyncWithConfirm).toBe('function'); expect(typeof mod.useSyncEnabledWriter).toBe('function'); + expect(typeof mod.useSyncDefaultWriter).toBe('function'); }); test('off to on opens confirmation and writes true only after confirm', async () => { @@ -169,3 +184,64 @@ describe('useSyncEnabledWriter runtime behavior', () => { }); }); }); + +describe('useSyncDefaultWriter runtime behavior', () => { + afterEach(() => { + cleanup(); + latestDefaultWriter = undefined; + projectBinding = null; + projectLocalBinding = null; + }); + + test('returns null until the committed project binding mounts', async () => { + await loadHooks(); + projectBinding = null; + render(); + + expect(screen.getByTestId('default-writer-present').textContent).toBe('false'); + expect(latestDefaultWriter).toBeNull(); + }); + + test('patches autoSync.default on the COMMITTED project binding, not project-local', async () => { + await loadHooks(); + const committedPatches: unknown[] = []; + const localPatches: unknown[] = []; + projectBinding = { + patch: (patch: unknown) => { + committedPatches.push(patch); + return { ok: true }; + }, + }; + projectLocalBinding = { + patch: (patch: unknown) => { + localPatches.push(patch); + return { ok: true }; + }, + }; + render(); + + expect(latestDefaultWriter?.(false)).toEqual({ ok: true }); + expect(committedPatches).toEqual([{ autoSync: { default: false } }]); + expect(localPatches).toEqual([]); + + expect(latestDefaultWriter?.(null)).toEqual({ ok: true }); + expect(committedPatches).toEqual([ + { autoSync: { default: false } }, + { autoSync: { default: null } }, + ]); + expect(localPatches).toEqual([]); + }); + + test('wraps binding errors into a string result for toast rendering', async () => { + await loadHooks(); + projectBinding = { + patch: () => ({ ok: false, error: { code: 'WRITE_ERROR', detail: 'disk denied' } }), + }; + render(); + + expect(latestDefaultWriter?.(true)).toEqual({ + ok: false, + error: 'Failed to write config file: disk denied', + }); + }); +}); diff --git a/packages/app/src/hooks/use-enable-sync-with-confirm.ts b/packages/app/src/hooks/use-enable-sync-with-confirm.ts index 6510070e..6945e466 100644 --- a/packages/app/src/hooks/use-enable-sync-with-confirm.ts +++ b/packages/app/src/hooks/use-enable-sync-with-confirm.ts @@ -15,6 +15,17 @@ export function useSyncEnabledWriter(): SyncEnabledWriter | null { }; } +type SyncDefaultWriter = (next: boolean | null) => { ok: true } | { ok: false; error: string }; + +export function useSyncDefaultWriter(): SyncDefaultWriter | null { + const { projectBinding } = useConfigContext(); + if (projectBinding === null) return null; + return (next: boolean | null) => { + const result = projectBinding.patch({ autoSync: { default: next } }); + return result.ok ? { ok: true } : { ok: false, error: humanFormat(result.error) }; + }; +} + interface UseEnableSyncWithConfirmResult { confirmOpen: boolean; setConfirmOpen: (open: boolean) => void; diff --git a/packages/app/src/hooks/use-terminal-enabled.dom.test.tsx b/packages/app/src/hooks/use-terminal-enabled.dom.test.tsx index bf194acd..af02a130 100644 --- a/packages/app/src/hooks/use-terminal-enabled.dom.test.tsx +++ b/packages/app/src/hooks/use-terminal-enabled.dom.test.tsx @@ -28,6 +28,7 @@ const emptyContext: ConfigContextValue = { userConfig: null, projectConfig: null, projectLocalConfig: null, + projectSynced: false, projectLocalSynced: false, merged: null, }; diff --git a/packages/app/src/lib/config-context.dom.test.tsx b/packages/app/src/lib/config-context.dom.test.tsx index 97879d35..4aacbf08 100644 --- a/packages/app/src/lib/config-context.dom.test.tsx +++ b/packages/app/src/lib/config-context.dom.test.tsx @@ -14,6 +14,7 @@ function makeContextValue(): ConfigContextValue { userConfig: null, projectConfig: null, projectLocalConfig: null, + projectSynced: false, projectLocalSynced: false, merged: { editor: { wordWrap: false } } as Config, }; diff --git a/packages/app/src/lib/config-context.ts b/packages/app/src/lib/config-context.ts index 52c8de8a..9ce91b50 100644 --- a/packages/app/src/lib/config-context.ts +++ b/packages/app/src/lib/config-context.ts @@ -10,6 +10,7 @@ export interface ConfigContextValue { okignoreSynced: boolean; userConfig: Config | null; projectConfig: Config | null; + projectSynced: boolean; projectLocalConfig: Config | null; projectLocalSynced: boolean; merged: Config | null; diff --git a/packages/app/src/lib/config-provider.tsx b/packages/app/src/lib/config-provider.tsx index d8deedb7..a0a77c5b 100644 --- a/packages/app/src/lib/config-provider.tsx +++ b/packages/app/src/lib/config-provider.tsx @@ -113,6 +113,7 @@ export function ConfigProvider({ const [projectState, setProjectState] = useState<{ binding: ConfigBinding; config: Config; + synced: boolean; } | null>(null); const [projectLocalState, setProjectLocalState] = useState<{ binding: ConfigBinding; @@ -145,7 +146,11 @@ export function ConfigProvider({ config: userScoped.config, synced: userScoped.binding.hasSynced(), }); - setProjectState({ binding: projectScoped.binding, config: projectScoped.config }); + setProjectState({ + binding: projectScoped.binding, + config: projectScoped.config, + synced: projectScoped.binding.hasSynced(), + }); setProjectLocalState({ binding: projectLocalScoped.binding, config: projectLocalScoped.config, @@ -168,6 +173,11 @@ export function ConfigProvider({ prev?.binding === projectScoped.binding ? { ...prev, config: next } : prev, ); }); + const unsubProjectSynced = projectScoped.binding.subscribeSynced(() => { + setProjectState((prev) => + prev?.binding === projectScoped.binding ? { ...prev, synced: true } : prev, + ); + }); const unsubProjectLocal = projectLocalScoped.binding.subscribe((next) => { setProjectLocalState((prev) => prev?.binding === projectLocalScoped.binding ? { ...prev, config: next } : prev, @@ -189,6 +199,7 @@ export function ConfigProvider({ unsubUser(); unsubUserSynced(); unsubProject(); + unsubProjectSynced(); unsubProjectLocal(); unsubProjectLocalSynced(); okignoreScoped.provider.off('synced', handleOkignoreSynced); @@ -235,6 +246,7 @@ export function ConfigProvider({ okignoreSynced: okignoreState?.synced ?? false, userConfig: userState?.config ?? null, projectConfig: projectState?.config ?? null, + projectSynced: projectState?.synced ?? false, projectLocalConfig: projectLocalState?.config ?? null, projectLocalSynced: projectLocalState?.synced ?? false, merged, diff --git a/packages/app/src/locales/en/messages.json b/packages/app/src/locales/en/messages.json index 53bb30d6..cc5db1ce 100644 --- a/packages/app/src/locales/en/messages.json +++ b/packages/app/src/locales/en/messages.json @@ -511,6 +511,7 @@ "EbZT7P": ["Toggle the left file sidebar."], "EcM3k7": ["Failed to clean up pending ", ["kind"]], "EcR0fl": ["Checking for installed agents"], + "EdQY6l": ["None"], "EeRZnZ": ["Expand burst ", ["burstNumber"], " diff"], "Ef7StM": ["Unknown"], "EhDLBx": ["You modified <0>", ["filePath"], " locally, but it was deleted upstream."], @@ -1012,6 +1013,9 @@ "W-ntoW": ["heading-slug"], "W0i24j": ["Object"], "W1qRuM": ["This document is already active in the editor. Use Open to collapse the graph."], + "W3Xlpc": [ + "Set the auto-sync default for users opening this project for the first time. This setting is committed to your repository." + ], "W5A0Ly": ["An unexpected error occurred."], "W8vpBM": ["starter pack"], "W9_Bub": ["Local changes overlap with incoming sync"], @@ -1099,6 +1103,7 @@ "Z1LF1M": ["Sharing a doc needs a GitHub repository. Create one for this project."], "Z2M5XM": ["Reason: ", ["reason"], " (", ["osDetail"], ")"], "Z3FXyt": ["Loading..."], + "Z5HWHd": ["On"], "Z5h97D": [ "Hide files and folders from your knowledge base. Hidden items don’t appear in the file tree, search, or AI tools. <0>Learn more about patterns." ], @@ -1194,6 +1199,7 @@ "aiArms": ["We couldn't clone this repository."], "ajz9F8": ["Couldn't load conflict content for ", ["filePath"], ". Try reloading the page."], "aoa6xA": ["network error (is `ok ui` running?)"], + "az8lvo": ["Off"], "b3Thhd": ["Upload failed"], "b4AQFK": ["Open activity panel for ", ["tooltipName"], ", editing ", ["realCurrentDoc"]], "b4itZn": ["Working"], @@ -1275,6 +1281,7 @@ ], "dprWmL": ["Anchor in this page"], "dr1vFs": ["Replace all matches"], + "dvGKu2": ["Shared auto-sync default"], "dw_dUJ": ["Failed to duplicate path"], "e0NrBM": ["Project"], "e1Rn_k": ["Open containing folder"], @@ -1432,6 +1439,7 @@ "jSY41h": ["\"", ["docName"], "\" took too long. Check your connection."], "jTLWxh": ["Embed an external page in an inline iframe (docs, demos, Figma, CodeSandbox)."], "jZ_E5M": ["Fetch failed — check the server logs for details."], + "j_Z6g7": ["Shared default"], "jbSG8V": ["Show Files (", ["sidebarShortcutLabel"], ")"], "jdGUeI": ["Couldn't create template: ", ["error"]], "jerqF0": ["Loading branch state"], @@ -1585,6 +1593,7 @@ "o5gfs1": [ "This setting is per-machine and isn't shared with collaborators. It needs an API key set with <0>ok embeddings set-key." ], + "oAFVKX": ["Failed to update the project sync default — ", ["detail"]], "oBXkm2": ["Delete code block"], "oCZkaG": ["Copy path"], "oJl7eO": ["Unknown component: ", ["componentName"], " — source editable below"], diff --git a/packages/app/src/locales/en/messages.po b/packages/app/src/locales/en/messages.po index 1e499be1..8cb9693a 100644 --- a/packages/app/src/locales/en/messages.po +++ b/packages/app/src/locales/en/messages.po @@ -2438,6 +2438,10 @@ msgstr "Failed to reorder" msgid "Failed to update property" msgstr "Failed to update property" +#: src/components/settings/SettingsDialogBody.tsx +msgid "Failed to update the project sync default — {detail}" +msgstr "Failed to update the project sync default — {detail}" + #: src/components/FileTree.tsx msgid "Failed to upload file" msgstr "Failed to upload file" @@ -3764,6 +3768,10 @@ msgstr "No templates yet. Add one to give new docs in this folder a ready-made s msgid "No workspace" msgstr "No workspace" +#: src/components/settings/SettingsDialogBody.tsx +msgid "None" +msgstr "None" + #: src/components/settings/AccountSection.tsx msgid "Not connected" msgstr "Not connected" @@ -3813,6 +3821,10 @@ msgstr "Object" msgid "of {totalPages}" msgstr "of {totalPages}" +#: src/components/settings/SettingsDialogBody.tsx +msgid "Off" +msgstr "Off" + #: src/components/settings/SearchSection.tsx msgid "Off — search ranks by keyword only. No content leaves this computer." msgstr "Off — search ranks by keyword only. No content leaves this computer." @@ -3829,6 +3841,10 @@ msgstr "OK Desktop" msgid "ok ui responded but server.lock has no port yet" msgstr "ok ui responded but server.lock has no port yet" +#: src/components/settings/SettingsDialogBody.tsx +msgid "On" +msgstr "On" + #: src/components/settings/SearchSection.tsx msgid "On — your search queries and the text of matching pages are sent to your embeddings provider (OpenAI by default) to compute embeddings." msgstr "On — your search queries and the text of matching pages are sent to your embeddings provider (OpenAI by default) to compute embeddings." @@ -5057,6 +5073,10 @@ msgstr "Set git identity" msgid "Set identity" msgstr "Set identity" +#: src/components/settings/SettingsDialogBody.tsx +msgid "Set the auto-sync default for users opening this project for the first time. This setting is committed to your repository." +msgstr "Set the auto-sync default for users opening this project for the first time. This setting is committed to your repository." + #: src/components/settings/SettingsDialogBody.tsx msgid "Set up syncing" msgstr "Set up syncing" @@ -5119,6 +5139,14 @@ msgstr "Share URL" msgid "Shared" msgstr "Shared" +#: src/components/settings/SettingsDialogBody.tsx +msgid "Shared auto-sync default" +msgstr "Shared auto-sync default" + +#: src/components/settings/SettingsDialogBody.tsx +msgid "Shared default" +msgstr "Shared default" + #: src/components/AutoSyncEnableWarning.tsx msgid "Shared repositories" msgstr "Shared repositories" @@ -5432,6 +5460,7 @@ msgid "Sync paused — you don't have permission to push to this repo" msgstr "Sync paused — you don't have permission to push to this repo" #: src/components/AutoSyncOnboardingDialog.tsx +#: src/components/settings/SettingsDialogBody.tsx #: src/hooks/use-enable-sync-with-confirm.ts msgid "Sync settings not yet loaded — try again in a moment" msgstr "Sync settings not yet loaded — try again in a moment" diff --git a/packages/app/src/locales/pseudo/messages.json b/packages/app/src/locales/pseudo/messages.json index 31407963..dd058c30 100644 --- a/packages/app/src/locales/pseudo/messages.json +++ b/packages/app/src/locales/pseudo/messages.json @@ -511,6 +511,7 @@ "EbZT7P": ["Ţōĝĝĺē ţĥē ĺēƒţ ƒĩĺē śĩďēƀàŕ."], "EcM3k7": ["Ƒàĩĺēď ţō ćĺēàń ũƥ ƥēńďĩńĝ ", ["kind"]], "EcR0fl": ["Ćĥēćķĩńĝ ƒōŕ ĩńśţàĺĺēď àĝēńţś"], + "EdQY6l": ["Ńōńē"], "EeRZnZ": ["Ēxƥàńď ƀũŕśţ ", ["burstNumber"], " ďĩƒƒ"], "Ef7StM": ["Ũńķńōŵń"], "EhDLBx": ["Ŷōũ ḿōďĩƒĩēď <0>", ["filePath"], " ĺōćàĺĺŷ, ƀũţ ĩţ ŵàś ďēĺēţēď ũƥśţŕēàḿ."], @@ -1012,6 +1013,9 @@ "W-ntoW": ["ĥēàďĩńĝ-śĺũĝ"], "W0i24j": ["ŌƀĴēćţ"], "W1qRuM": ["Ţĥĩś ďōćũḿēńţ ĩś àĺŕēàďŷ àćţĩvē ĩń ţĥē ēďĩţōŕ. Ũśē Ōƥēń ţō ćōĺĺàƥśē ţĥē ĝŕàƥĥ."], + "W3Xlpc": [ + "Śēţ ţĥē àũţō-śŷńć ďēƒàũĺţ ƒōŕ ũśēŕś ōƥēńĩńĝ ţĥĩś ƥŕōĴēćţ ƒōŕ ţĥē ƒĩŕśţ ţĩḿē. Ţĥĩś śēţţĩńĝ ĩś ćōḿḿĩţţēď ţō ŷōũŕ ŕēƥōśĩţōŕŷ." + ], "W5A0Ly": ["Àń ũńēxƥēćţēď ēŕŕōŕ ōććũŕŕēď."], "W8vpBM": ["śţàŕţēŕ ƥàćķ"], "W9_Bub": ["Ĺōćàĺ ćĥàńĝēś ōvēŕĺàƥ ŵĩţĥ ĩńćōḿĩńĝ śŷńć"], @@ -1099,6 +1103,7 @@ "Z1LF1M": ["Śĥàŕĩńĝ à ďōć ńēēďś à ĜĩţĤũƀ ŕēƥōśĩţōŕŷ. Ćŕēàţē ōńē ƒōŕ ţĥĩś ƥŕōĴēćţ."], "Z2M5XM": ["Ŕēàśōń: ", ["reason"], " (", ["osDetail"], ")"], "Z3FXyt": ["Ĺōàďĩńĝ..."], + "Z5HWHd": ["Ōń"], "Z5h97D": [ "Ĥĩďē ƒĩĺēś àńď ƒōĺďēŕś ƒŕōḿ ŷōũŕ ķńōŵĺēďĝē ƀàśē. Ĥĩďďēń ĩţēḿś ďōń’ţ àƥƥēàŕ ĩń ţĥē ƒĩĺē ţŕēē, śēàŕćĥ, ōŕ ÀĨ ţōōĺś. <0>Ĺēàŕń ḿōŕē àƀōũţ ƥàţţēŕńś." ], @@ -1194,6 +1199,7 @@ "aiArms": ["Ŵē ćōũĺďń'ţ ćĺōńē ţĥĩś ŕēƥōśĩţōŕŷ."], "ajz9F8": ["Ćōũĺďń'ţ ĺōàď ćōńƒĺĩćţ ćōńţēńţ ƒōŕ ", ["filePath"], ". Ţŕŷ ŕēĺōàďĩńĝ ţĥē ƥàĝē."], "aoa6xA": ["ńēţŵōŕķ ēŕŕōŕ (ĩś `ōķ ũĩ` ŕũńńĩńĝ?)"], + "az8lvo": ["Ōƒƒ"], "b3Thhd": ["Ũƥĺōàď ƒàĩĺēď"], "b4AQFK": ["Ōƥēń àćţĩvĩţŷ ƥàńēĺ ƒōŕ ", ["tooltipName"], ", ēďĩţĩńĝ ", ["realCurrentDoc"]], "b4itZn": ["Ŵōŕķĩńĝ"], @@ -1275,6 +1281,7 @@ ], "dprWmL": ["Àńćĥōŕ ĩń ţĥĩś ƥàĝē"], "dr1vFs": ["Ŕēƥĺàćē àĺĺ ḿàţćĥēś"], + "dvGKu2": ["Śĥàŕēď àũţō-śŷńć ďēƒàũĺţ"], "dw_dUJ": ["Ƒàĩĺēď ţō ďũƥĺĩćàţē ƥàţĥ"], "e0NrBM": ["ƤŕōĴēćţ"], "e1Rn_k": ["Ōƥēń ćōńţàĩńĩńĝ ƒōĺďēŕ"], @@ -1432,6 +1439,7 @@ "jSY41h": ["\"", ["docName"], "\" ţōōķ ţōō ĺōńĝ. Ćĥēćķ ŷōũŕ ćōńńēćţĩōń."], "jTLWxh": ["Ēḿƀēď àń ēxţēŕńàĺ ƥàĝē ĩń àń ĩńĺĩńē ĩƒŕàḿē (ďōćś, ďēḿōś, Ƒĩĝḿà, ĆōďēŚàńďƀōx)."], "jZ_E5M": ["Ƒēţćĥ ƒàĩĺēď — ćĥēćķ ţĥē śēŕvēŕ ĺōĝś ƒōŕ ďēţàĩĺś."], + "j_Z6g7": ["Śĥàŕēď ďēƒàũĺţ"], "jbSG8V": ["Śĥōŵ Ƒĩĺēś (", ["sidebarShortcutLabel"], ")"], "jdGUeI": ["Ćōũĺďń'ţ ćŕēàţē ţēḿƥĺàţē: ", ["error"]], "jerqF0": ["Ĺōàďĩńĝ ƀŕàńćĥ śţàţē"], @@ -1585,6 +1593,7 @@ "o5gfs1": [ "Ţĥĩś śēţţĩńĝ ĩś ƥēŕ-ḿàćĥĩńē àńď ĩśń'ţ śĥàŕēď ŵĩţĥ ćōĺĺàƀōŕàţōŕś. Ĩţ ńēēďś àń ÀƤĨ ķēŷ śēţ ŵĩţĥ <0>ōķ ēḿƀēďďĩńĝś śēţ-ķēŷ." ], + "oAFVKX": ["Ƒàĩĺēď ţō ũƥďàţē ţĥē ƥŕōĴēćţ śŷńć ďēƒàũĺţ — ", ["detail"]], "oBXkm2": ["Ďēĺēţē ćōďē ƀĺōćķ"], "oCZkaG": ["Ćōƥŷ ƥàţĥ"], "oJl7eO": ["Ũńķńōŵń ćōḿƥōńēńţ: ", ["componentName"], " — śōũŕćē ēďĩţàƀĺē ƀēĺōŵ"], diff --git a/packages/app/src/locales/pseudo/messages.po b/packages/app/src/locales/pseudo/messages.po index 77a6d655..c69d9fdb 100644 --- a/packages/app/src/locales/pseudo/messages.po +++ b/packages/app/src/locales/pseudo/messages.po @@ -2433,6 +2433,10 @@ msgstr "" msgid "Failed to update property" msgstr "" +#: src/components/settings/SettingsDialogBody.tsx +msgid "Failed to update the project sync default — {detail}" +msgstr "" + #: src/components/FileTree.tsx msgid "Failed to upload file" msgstr "" @@ -3759,6 +3763,10 @@ msgstr "" msgid "No workspace" msgstr "" +#: src/components/settings/SettingsDialogBody.tsx +msgid "None" +msgstr "" + #: src/components/settings/AccountSection.tsx msgid "Not connected" msgstr "" @@ -3808,6 +3816,10 @@ msgstr "" msgid "of {totalPages}" msgstr "" +#: src/components/settings/SettingsDialogBody.tsx +msgid "Off" +msgstr "" + #: src/components/settings/SearchSection.tsx msgid "Off — search ranks by keyword only. No content leaves this computer." msgstr "" @@ -3824,6 +3836,10 @@ msgstr "" msgid "ok ui responded but server.lock has no port yet" msgstr "" +#: src/components/settings/SettingsDialogBody.tsx +msgid "On" +msgstr "" + #: src/components/settings/SearchSection.tsx msgid "On — your search queries and the text of matching pages are sent to your embeddings provider (OpenAI by default) to compute embeddings." msgstr "" @@ -5052,6 +5068,10 @@ msgstr "" msgid "Set identity" msgstr "" +#: src/components/settings/SettingsDialogBody.tsx +msgid "Set the auto-sync default for users opening this project for the first time. This setting is committed to your repository." +msgstr "" + #: src/components/settings/SettingsDialogBody.tsx msgid "Set up syncing" msgstr "" @@ -5114,6 +5134,14 @@ msgstr "" msgid "Shared" msgstr "" +#: src/components/settings/SettingsDialogBody.tsx +msgid "Shared auto-sync default" +msgstr "" + +#: src/components/settings/SettingsDialogBody.tsx +msgid "Shared default" +msgstr "" + #: src/components/AutoSyncEnableWarning.tsx msgid "Shared repositories" msgstr "" @@ -5427,6 +5455,7 @@ msgid "Sync paused — you don't have permission to push to this repo" msgstr "" #: src/components/AutoSyncOnboardingDialog.tsx +#: src/components/settings/SettingsDialogBody.tsx #: src/hooks/use-enable-sync-with-confirm.ts msgid "Sync settings not yet loaded — try again in a moment" msgstr "" diff --git a/packages/core/src/config/field-registry.test.ts b/packages/core/src/config/field-registry.test.ts index ae7cf8b1..c469bbe9 100644 --- a/packages/core/src/config/field-registry.test.ts +++ b/packages/core/src/config/field-registry.test.ts @@ -136,7 +136,7 @@ describe('ConfigSchema coverage (NR3 — every leaf has fieldRegistry metadata)' ]); }); - test('project-strict fields cover content.dir + telemetry.localSink.*', () => { + test('project-strict fields cover autoSync.default + content.dir + telemetry.localSink.*', () => { const leaves: { path: string[]; schema: unknown }[] = []; walkLeaves(ConfigSchema, [], leaves); const projectStrict = leaves @@ -144,6 +144,7 @@ describe('ConfigSchema coverage (NR3 — every leaf has fieldRegistry metadata)' .map((l) => l.path.join('.')) .sort(); expect(projectStrict).toEqual([ + 'autoSync.default', 'content.dir', 'telemetry.localSink.attributeDenylist', 'telemetry.localSink.enabled', diff --git a/packages/core/src/config/schema.ts b/packages/core/src/config/schema.ts index bf4b21e5..8429e842 100644 --- a/packages/core/src/config/schema.ts +++ b/packages/core/src/config/schema.ts @@ -104,8 +104,19 @@ export const ConfigSchema = z.looseObject({ }) .nullable() .default(null), + default: z + .boolean() + .register(fieldRegistry, { + scope: 'project', + agentSettable: false, + defaultScope: 'project', + description: + "Committed project default for a machine's autoSync.enabled on first open: true = auto-sync on, false = off, null = ask (show the onboarding prompt). Shared via git. A per-machine autoSync.enabled choice overrides it.", + }) + .nullable() + .default(null), }) - .default({ enabled: null }), + .default({ enabled: null, default: null }), terminal: z .looseObject({ enabled: z diff --git a/packages/core/src/config/validate-patch-scopes.test.ts b/packages/core/src/config/validate-patch-scopes.test.ts index d59aee73..46582f99 100644 --- a/packages/core/src/config/validate-patch-scopes.test.ts +++ b/packages/core/src/config/validate-patch-scopes.test.ts @@ -28,6 +28,27 @@ describe('validatePatchScopes', () => { expect(violation?.actualScope).toBe('user'); }); + test('returns null for autoSync.default (project) written by a project writer', () => { + expect(validatePatchScopes({ autoSync: { default: false } }, 'project')).toBeNull(); + expect(validatePatchScopes({ autoSync: { default: true } }, 'project')).toBeNull(); + expect(validatePatchScopes({ autoSync: { default: null } }, 'project')).toBeNull(); + }); + + test('returns SCOPE_VIOLATION for autoSync.default written by a project-local writer', () => { + const violation = validatePatchScopes({ autoSync: { default: false } }, 'project-local'); + expect(violation?.code).toBe('SCOPE_VIOLATION'); + expect(violation?.path).toEqual(['autoSync', 'default']); + expect(violation?.expectedScope).toBe('project'); + expect(violation?.actualScope).toBe('project-local'); + }); + + test('returns SCOPE_VIOLATION for autoSync.default written by a user writer', () => { + const violation = validatePatchScopes({ autoSync: { default: true } }, 'user'); + expect(violation?.code).toBe('SCOPE_VIOLATION'); + expect(violation?.expectedScope).toBe('project'); + expect(violation?.actualScope).toBe('user'); + }); + test('returns SCOPE_VIOLATION for a user field written by a project writer', () => { const violation = validatePatchScopes({ appearance: { theme: 'dark' } }, 'project'); expect(violation?.code).toBe('SCOPE_VIOLATION'); diff --git a/packages/server/src/server-factory.test.ts b/packages/server/src/server-factory.test.ts index a8e15ac4..ad1560ae 100644 --- a/packages/server/src/server-factory.test.ts +++ b/packages/server/src/server-factory.test.ts @@ -777,6 +777,31 @@ describe('createServer() — project-local file watcher → engine.setEnabled', await srv.destroy(); }); + + test('external write of committed autoSync.default: true flips engine state (unanswered machine)', async () => { + const contentDir = mkdtempSync(resolve(testProjectDir, 'content-')); + const srv = createServer({ + contentDir, + projectDir: testProjectDir, + quiet: true, + configHomedirOverride: testHomedir, + }); + await srv.ready; + + expect(srv.syncEngine?.getStatus().syncEnabled).toBe(false); + + mkdirSync(join(testProjectDir, '.ok'), { recursive: true }); + writeFileSync( + join(testProjectDir, '.ok', 'config.yml'), + 'autoSync:\n default: true\n', + 'utf-8', + ); + + const flipped = await waitFor(() => srv.syncEngine?.getStatus().syncEnabled === true); + expect(flipped).toBe(true); + + await srv.destroy(); + }); }); describe('createServer() — okignore + gitignore multi-path watcher (US-005)', () => { @@ -1728,8 +1753,8 @@ describe('createServer() — readProjectAutoSyncEnabled precedence', () => { await srv.destroy(); }); - test('project-local absent + project autoSync.enabled: true → engine boots enabled (legacy fallback)', async () => { - seedProjectConfig('autoSync:\n enabled: true\n'); + test('project-local absent + committed autoSync.default: true → engine boots enabled', async () => { + seedProjectConfig('autoSync:\n default: true\n'); const contentDir = mkdtempSync(resolve(testProjectDir, 'content-')); const srv = createServer({ contentDir, @@ -1742,6 +1767,34 @@ describe('createServer() — readProjectAutoSyncEnabled precedence', () => { await srv.destroy(); }); + test('project-local absent + committed autoSync.default: false → engine boots disabled', async () => { + seedProjectConfig('autoSync:\n default: false\n'); + const contentDir = mkdtempSync(resolve(testProjectDir, 'content-')); + const srv = createServer({ + contentDir, + projectDir: testProjectDir, + quiet: true, + configHomedirOverride: testHomedir, + }); + await srv.ready; + expect(srv.syncEngine?.getStatus().syncEnabled).toBe(false); + await srv.destroy(); + }); + + test('committed autoSync.enabled is ignored (scope-mismatched) → engine boots disabled', async () => { + seedProjectConfig('autoSync:\n enabled: true\n'); + const contentDir = mkdtempSync(resolve(testProjectDir, 'content-')); + const srv = createServer({ + contentDir, + projectDir: testProjectDir, + quiet: true, + configHomedirOverride: testHomedir, + }); + await srv.ready; + expect(srv.syncEngine?.getStatus().syncEnabled).toBe(false); + await srv.destroy(); + }); + test('both absent → engine boots disabled (default)', async () => { const contentDir = mkdtempSync(resolve(testProjectDir, 'content-')); const srv = createServer({ @@ -1755,9 +1808,9 @@ describe('createServer() — readProjectAutoSyncEnabled precedence', () => { await srv.destroy(); }); - test('project-local autoSync.enabled: false short-circuits — does NOT fall through to project: true', async () => { + test('project-local enabled: false beats committed default: true (machine override wins)', async () => { seedProjectLocalConfig('autoSync:\n enabled: false\n'); - seedProjectConfig('autoSync:\n enabled: true\n'); + seedProjectConfig('autoSync:\n default: true\n'); const contentDir = mkdtempSync(resolve(testProjectDir, 'content-')); const srv = createServer({ contentDir, @@ -1770,9 +1823,24 @@ describe('createServer() — readProjectAutoSyncEnabled precedence', () => { await srv.destroy(); }); - test('project-local autoSync.enabled: null falls through to project: true', async () => { + test('project-local enabled: true beats committed default: false (machine override wins)', async () => { + seedProjectLocalConfig('autoSync:\n enabled: true\n'); + seedProjectConfig('autoSync:\n default: false\n'); + const contentDir = mkdtempSync(resolve(testProjectDir, 'content-')); + const srv = createServer({ + contentDir, + projectDir: testProjectDir, + quiet: true, + configHomedirOverride: testHomedir, + }); + await srv.ready; + expect(srv.syncEngine?.getStatus().syncEnabled).toBe(true); + await srv.destroy(); + }); + + test('project-local autoSync.enabled: null falls through to committed default: true', async () => { seedProjectLocalConfig('autoSync:\n enabled: null\n'); - seedProjectConfig('autoSync:\n enabled: true\n'); + seedProjectConfig('autoSync:\n default: true\n'); const contentDir = mkdtempSync(resolve(testProjectDir, 'content-')); const srv = createServer({ contentDir, @@ -1785,9 +1853,9 @@ describe('createServer() — readProjectAutoSyncEnabled precedence', () => { await srv.destroy(); }); - test('invalid project-local YAML falls through to project (degraded path)', async () => { + test('invalid project-local YAML falls through to committed default (degraded path)', async () => { seedProjectLocalConfig('autoSync:\n enabled: : not-yaml [[[\n'); - seedProjectConfig('autoSync:\n enabled: true\n'); + seedProjectConfig('autoSync:\n default: true\n'); const contentDir = mkdtempSync(resolve(testProjectDir, 'content-')); const srv = createServer({ contentDir, @@ -1799,6 +1867,20 @@ describe('createServer() — readProjectAutoSyncEnabled precedence', () => { expect(srv.syncEngine?.getStatus().syncEnabled).toBe(true); await srv.destroy(); }); + + test('invalid committed config defaults to disabled (degraded path)', async () => { + seedProjectConfig('autoSync:\n default: : not-yaml [[[\n'); + const contentDir = mkdtempSync(resolve(testProjectDir, 'content-')); + const srv = createServer({ + contentDir, + projectDir: testProjectDir, + quiet: true, + configHomedirOverride: testHomedir, + }); + await srv.ready; + expect(srv.syncEngine?.getStatus().syncEnabled).toBe(false); + await srv.destroy(); + }); }); describe('createServer() — onAutoDisable scope pinning', () => { diff --git a/packages/server/src/server-factory.ts b/packages/server/src/server-factory.ts index 7e15bf69..091d58c9 100644 --- a/packages/server/src/server-factory.ts +++ b/packages/server/src/server-factory.ts @@ -240,7 +240,7 @@ export function createServer(options: ServerOptions): ServerInstance { if (!local.valid) { log.warn( {}, - '[config] project-local autoSync.enabled unavailable (config invalid) — falling back to project config', + '[config] project-local autoSync.enabled unavailable (config invalid) — falling back to the committed project default', ); } const project = readConfigSafely({ @@ -248,7 +248,13 @@ export function createServer(options: ServerOptions): ServerInstance { sideline: false, warn: (message) => log.warn({ message }, '[config] could not read project config'), }); - return project.value.autoSync?.enabled === true; + if (!project.valid) { + log.warn( + {}, + '[config] committed autoSync.default unavailable (project config invalid) — defaulting to disabled', + ); + } + return project.value.autoSync?.default === true; } function readSemanticSearchConfig(): ResolvedSemanticConfig {