Repository navigation
Move the workspace sync engine into @deepnote/cloud-sync #563
Description
Objective
deepnote sync (packages/cli/src/commands/sync.ts) can only be used as a CLI command. It resolves its root from process.cwd(), loads .env into process.env, prompts through @inquirer/prompts and prints through the CLI output module.
After this change, @deepnote/cloud-sync exports syncWorkspace(options), which syncs every project of a Deepnote workspace with a local folder. The caller passes the root, token and API URL explicitly, supplies a policy that decides how each conflict is resolved, and receives progress through an event callback. deepnote sync becomes a thin adapter over it, with unchanged flags, output, JSON and exit codes. @deepnote/cli is the first consumer; the MCP server (#544) and the VS Code extension follow.
Scope
- In scope:
- Moving the engine and its helpers from
commands/sync.tstopackages/cloud-sync/src/sync-workspace.ts. - The library contract: options, result, conflicts, events and cancellation.
- The CLI adapter.
- Moving and converting the engine test suite.
- New CLI adapter tests.
- Package dependency and README updates.
- Moving the engine and its helpers from
- Out of scope:
- New sync behavior: a per-project filter, a reusable dry-run plan, an override that pushes local changes for a project changed on both sides,
AbortSignalcancellation. - JSON error output.
- Changes to option registration in
cli.ts. - Changes to
@deepnote/cloud. - Publishing and Streamlit code.
- Changes to
docs/orskills/.
- New sync behavior: a per-project filter, a reusable dry-run plan, an override that pushes local changes for a project changed on both sides,
Relevant context
Precondition. packages/cloud-sync exists and owns sync-manifest.ts, sync-paths.ts and fs-errors.ts (added in #554). commands/sync.ts imports the manifest and path symbols from @deepnote/cloud-sync, and commands/sync.test.ts wraps saveSyncManifest and assertNoSymbolicLinkAncestors through vi.mock('@deepnote/cloud-sync', …).
packages/cli/src/commands/sync.ts today
-
CLI-only symbols that stay in the CLI:
CONFLICT_MODESandConflictMode('ask' | 'skip' | 'override').parseSyncConcurrency, which throws commander'sInvalidArgumentError.SyncOptions:url,token,allFiles,onConflict,deleteMissingNotebooks,prune,dryRun,output?: 'json'andconcurrency.renderOutcomeLineandrenderHumanSummary.createSyncAction. It printsoutputJson(result)orrenderHumanSummary, setsprocess.exitCode = 1when!success, and throughprogram.errormapsMissingTokenErrorto exit code 2 and every other error to exit code 1.cli.tsimportsCONFLICT_MODES,DEFAULT_SYNC_CONCURRENCY,parseSyncConcurrencyandcreateSyncAction.
-
Engine symbols that move:
DEFAULT_SYNC_CONCURRENCY(8).ProjectSyncOutcome, andSyncResult({success, root, dryRun, projects, untrackedFiles}, which is also the-o jsonshape).SyncContext,SyncCancelledError,throwIfCancelled,emit,assertBufferedProjectFileSize.canonicalProjectHash,parseDocumentLoosely,readExportModifiedAt,SyncStep,classifySyncStep,resolveConflict.writeFileEnsuringDir,pathExists,toAbsolute,readLocalNotebookFiles,writeProjectNotebooks,moveTrackedProjectDir.syncProjectFiles, the privatePushOutcome,pushProject,listLocalFilesRecursive,describeCloudFileDivergence,PlannedFileUpload,uploadProjectFiles,syncOneProject,findUntrackedDeepnoteFiles.- The body of
syncWorkspace(dir, options).
-
Environment coupling in
syncWorkspace:path.resolve(process.cwd(), dir ?? '.').fs.mkdir(rootDir), unless it is a dry run.dotenv.config({ path: <root>/.env, quiet: true }).resolveToken(options.token), which leads toMissingTokenErrorwhen there is no token.canPrompt = stdin.isTTY && stdout.isTTY && !output.conflictMode = requested === 'ask' && (!canPrompt || dryRun) ? 'skip' : requested. When the mode isaskandcanPromptis false, it also callsdebug('No interactive terminal; conflicts will be skipped. Use --on-conflict to decide up front.').- Progress lines go through
emit(ctx, log, …), and only whenoutputis unset.
-
resolveConflict(ctx, question, overrideLabel):- For
skipandoverrideit returnsconflictModeimmediately. - For
askit chains aselectprompt (choicesSkip this project for nowandoverrideLabel) ontoctx.promptQueue, so only one prompt is open at a time. While the prompt is open it setsctx.heldOutput = [], and flushes the held output in order afterwards. - If the prompt rejects with
ExitPromptError, it setsctx.cancelled, and queued prompts reject with the same error. syncOneProjectrethrows prompt exits andSyncCancelledErrorinstead of recording anerroroutcome.- Workers stop at their
throwIfCancelledcheckpoints. AfterPromise.allSettled, a rejected worker triggers a final manifest save (skipped in a dry run), then the rejection is rethrown.
- For
-
The five
resolveConflictcall sites, and whatoverridedoes at each:Call site Situation What overridedoespushProjectEmpty local directory with deleteMissingNotebooksPush and delete every cloud notebook pushProjectImport returns 409 (other than "Project is suspended") Re-import with force: trueuploadProjectFilesWorking files changed in Deepnote; one call per project with {relPath, conflict}entriesUpload the local copies syncOneProjectTracked project changed on both sides Pull the cloud version syncOneProjectUntracked local directory that differs from the cloud Pull the cloud version -
Output emitted by the engine:
- Warnings via
emit(ctx, warn, …): one inwriteProjectNotebooks, two insyncProjectFilesand two inuploadProjectFiles. - Debug lines
Downloaded ${project.name}: ${entry.path} (${entry.size} bytes)andUploaded ${project.name}: ${relPath} (${bytes.length} bytes). - Progress:
Listing projects from ${baseUrl}…(dim), and onerenderOutcomeLine(outcome)per project that finishes, is pruned or is missing in the cloud.
- Warnings via
-
Engine imports that move with it:
node:fs/promises,node:pathand the globalcrypto.randomUUID.parseYamlfrom@deepnote/blocks.ApiErrorfrom@deepnote/database-integrations.- From
@deepnote/cloud:deleteProjectFile,downloadProjectFile,exportProject,getProjectDetail,importProject,listAllProjects,uploadProjectFile,MAX_BUFFERED_PROJECT_FILE_BYTES, and the typesExportedNotebookFile,ImportedNotebook,ProjectFileEntryandSyncProject. isErrnoENOENTnow comes frompackages/cloud-sync/src/fs-errors.ts.
packages/cli/src/commands/sync.test.ts today (72 tests)
- Calls
syncWorkspace(tempDir, { ...baseOptions, … })directly. - Fakes the API with
vi.spyOn(global, 'fetch')ininstallCloud. - Mocks
selectfrom@inquirer/prompts. - Stubs TTYs with
Object.defineProperty(process.stdin/stdout, 'isTTY')andwithTty. - Reads progress from
console.logand warnings fromconsole.errorin nine tests, including the exact string'Skipping file with unsafe path in "C": ../evil.csv'. - Spies on
saveSyncManifestandassertNoSymbolicLinkAncestorsto observe manifest saves and the order in which projects start. createSyncAction,renderHumanSummaryand the JSON output have no tests.
Name clash. @deepnote/local-runner already exports types named SyncOptions and SyncResult, so the library's public names must differ.
Completion criteria
@deepnote/cloud-syncexportssyncWorkspace,DEFAULT_SYNC_CONCURRENCYand the types listed in step 1.packages/cli/src/commands/sync.tsno longer contains engine code: noexportProjectorimportProjectcalls.deepnote syncproduces the same stdout, stderr,-o jsondocument and exit code as before for every flag combination, as the converted and new tests show.syncWorkspacenever reads or writesprocess.env: with a.envfile containingDEEPNOTE_TOKENinrootDir,process.envis unchanged after a run.- With a function policy, the function:
- is never called concurrently;
- is never called in a dry run;
- when it rejects, makes
syncWorkspacereject with that same error once every worker has settled. Cancellation takes effect at the existingthrowIfCancelledcheckpoints: no project, import, file download, prune deletion or file replacement starts after the rejection. Work already past its checkpoint completes, including a file replacement whose delete was already sent (its upload and any cleanup delete still run) and a notebook write batch already in progress. Unless it is a dry run, a final manifest save is attempted before the rejection; a failed save is ignored.
syncWorkspacerejects aconcurrencythat is not a positive integer withRangeErrorbefore it createsrootDiror calls the API.- Outside
*.test.tsfiles,packages/cloud-sync/srcstill has no import fromcommander,chalk,ora,@inquirer/prompts,dotenvor@deepnote/cli, and noprocess.reference.
Implementation
- Define the library contract
-
File to create:
packages/cloud-sync/src/sync-workspace.ts. -
Exported API:
export const DEFAULT_SYNC_CONCURRENCY = 8 export type SyncConflictDecision = 'skip' | 'override' export type SyncConflict = | { kind: 'empty-local-directory'; projectId: string; projectName: string } | { kind: 'cloud-changed-after-local-edit'; projectId: string; projectName: string } | { kind: 'working-files-changed'; projectId: string; projectName: string; projectDir: string; files: { path: string; reason: string }[] } | { kind: 'changed-on-both-sides'; projectId: string; projectName: string; projectDir: string } | { kind: 'untracked-local-directory'; projectId: string; projectName: string; projectDir: string } export type SyncConflictPolicy = | SyncConflictDecision | ((conflict: SyncConflict) => Promise<SyncConflictDecision>) export type SyncEvent = | { kind: 'listing-projects'; baseUrl: string } | { kind: 'project-outcome'; outcome: ProjectSyncOutcome } | { kind: 'warning'; message: string } | { kind: 'file-transferred'; direction: 'download' | 'upload'; projectName: string; path: string; bytes: number } export interface WorkspaceSyncOptions { rootDir: string baseUrl: string token: string allFiles?: boolean deleteMissingNotebooks?: boolean prune?: boolean dryRun?: boolean concurrency?: number // positive integer; default DEFAULT_SYNC_CONCURRENCY onConflict?: SyncConflictPolicy // default 'skip' onEvent?: (event: SyncEvent) => void } export interface ProjectSyncOutcome { /* moved unchanged from commands/sync.ts */ } export interface WorkspaceSyncResult { /* moved unchanged from SyncResult in commands/sync.ts */ } export function syncWorkspace(options: WorkspaceSyncOptions): Promise<WorkspaceSyncResult>
-
Each
SyncConflictvariant has TSDoc stating whatoverridedoes (see the table in Relevant context). The TSDoc onprojectDirstates that it is a POSIX path relative torootDir, e.g.Analytics/Sales report. The TSDoc onfiles[].pathand on thefile-transferredevent'spathstates that each is the working file's project-relative POSIX path: its path in Deepnote, and locally relative to<rootDir>/<projectDir>/.files, never prefixed withprojectDir, e.g._deepnote_static/index.html. -
Export everything above from
packages/cloud-sync/src/index.ts.
-
- Move the engine behind the contract
- Move the engine symbols listed in Relevant context from
commands/sync.tsintosync-workspace.ts. They stay unchanged except as follows:syncWorkspacefirst validatesoptions.concurrency: when it is defined and!Number.isInteger(concurrency) || concurrency < 1, the call rejects withnew RangeError('concurrency must be a positive integer')before it createsrootDiror calls the API, followingprojectFileTransferLimitinpackages/cloud/src/sync.ts. The CLI keepsparseSyncConcurrencyand itsInvalidArgumentErrormessage.SyncContextholdsWorkspaceSyncOptionsand theonConflictpolicy instead of the CLI'sSyncOptionsandconflictMode.syncWorkspaceresolvesrootDirwithpath.resolve(options.rootDir), creates it unlessdryRunis set, and usesoptions.baseUrlandoptions.tokenas given. It does not calldotenv,resolveTokenorprocess.cwd(), and it does no TTY checks.emitdeliversSyncEvents tooptions.onEventand keeps the hold-and-flush behavior:- The warning call sites emit
{ kind: 'warning', message }with the current message text. - The two debug call sites emit
file-transferred, usingentry.sizefor downloads andbytes.lengthfor uploads. - The
Listing projectsprogress line becomes alisting-projectsevent. - Every
progress(renderOutcomeLine(outcome))call becomes aproject-outcomeevent.
- The warning call sites emit
resolveConflict(ctx, conflict)takes aSyncConflict:- A string policy returns immediately, with no queueing and no holding.
- A function policy is treated as
'skip'whendryRunis set. Otherwise the function is called through the existing promise queue, so only one call is outstanding at a time. Events are held while a call is pending and flushed in order once it settles. - If the function rejects, the engine records that error as the cancellation reason and sets
ctx.cancelled. Queued calls then reject with the same error without calling the function.
syncOneProjectrethrows the cancellation-reason error andSyncCancelledError, replacing theisPromptExitcheck. DeleteisPromptExit. Worker shutdown, the final manifest save on rejection, and the rethrow stay as they are. Keep everythrowIfCancelledcheckpoint where it is; add none betweendeleteProjectFileanduploadProjectFileinuploadProjectFiles.- Each call site builds its
SyncConflictvariant from data it already has:project.id,project.name,plan.projectDir, and the{relPath, conflict}list mapped to{path, reason}.
- In
packages/cloud-sync/package.json, add"@deepnote/blocks": "workspace:*"and"@deepnote/database-integrations": "workspace:*"todependenciesif they are not there yet. Add both toexternalinpackages/cloud-sync/tsdown.config.ts. Runpnpm install.
- Move the engine symbols listed in Relevant context from
- Turn the CLI command into an adapter
- File to modify:
packages/cli/src/commands/sync.ts. - Keep
CONFLICT_MODES,ConflictMode,parseSyncConcurrency,SyncOptions,renderOutcomeLine,renderHumanSummaryandcreateSyncAction. Re-exportDEFAULT_SYNC_CONCURRENCYfrom@deepnote/cloud-syncsocli.tsneeds no change. ReplaceProjectSyncOutcomeandSyncResultwith the library types. - Replace
syncWorkspacewith an exportedrunSync(dir: string | undefined, options: SyncOptions): Promise<WorkspaceSyncResult>that does the following, in this order:- Resolves
rootDir = path.resolve(process.cwd(), dir ?? '.'). - Creates the root unless
dryRunis set. - Loads
<root>/.envwithdotenv.config. - Resolves the token with
resolveToken(options.token)and throwsMissingTokenErrorwhen there is none. - Picks the conflict policy:
- Computes
canPrompt = Boolean(process.stdin.isTTY && process.stdout.isTTY) && options.output === undefined, unchanged from today. askwithcanPromptfalse (no TTY, or-o jsoneven on a TTY) →'skip', plus the existingdebug('No interactive terminal; conflicts will be skipped. Use --on-conflict to decide up front.')line.askwithcanPrompttrue → a function, also whendryRunis set; the library then treats it as'skip', and no debug line is printed.skiporoverride→ that string.
- Computes
- Calls the library's
syncWorkspacewithbaseUrl: options.url ?? DEFAULT_API_URLand the remaining flags.
- Resolves
- The
askfunction callsselectwith amessageand an override choice label produced by a new privatedescribeConflict(conflict: SyncConflict): { question: string; overrideLabel: string }. For eachkind,describeConflictreturns exactly the question and override label that the corresponding call site passes toresolveConflicttoday. Forworking-files-changed, the summary inside the question isfiles.map(f => `${f.path} (${f.reason})`).join(', '). The skip choice staysSkip this project for now. - The
onEventhandler maps events as follows:listing-projects→log(getChalk().dim(`Listing projects from ${baseUrl}…`)), only whenoptions.outputis unset.project-outcome→log(renderOutcomeLine(outcome)), only whenoptions.outputis unset.warning→warn(message).file-transferred→debug, with the existingDownloaded …andUploaded …text.
createSyncActioncallsrunSync. Its output and exit-code handling are unchanged;ExitPromptErrorstill exits with code 1.
- File to modify:
- Move and convert the engine tests, and add adapter tests
- Move
packages/cli/src/commands/sync.test.tstopackages/cloud-sync/src/sync-workspace.test.tswithgit mvand convert it in place. Test names and expected values stay unchanged:- Calls pass
rootDir,baseUrlandtokenexplicitly. - Prompt-driven tests pass an
onConflictvi.fninstead of mockingselectand stubbing TTYs; the Ctrl+C tests reject from it with the same error object. The converted prompt tests then cover the never-concurrent and hold-and-flush criteria. - Assertions on
console.logandconsole.errorbecome assertions on the collectedSyncEvents, with the same message strings. - Delete the tests whose subject is TTY detection, the
selectchoices, or.envand token handling; the CLI adapter tests cover that behavior.
- Calls pass
- Move
fflate(the suite's archive fixtures) fromdevDependenciesinpackages/cli/package.jsontodevDependenciesinpackages/cloud-sync/package.json, neverdependencies; nothing else in the CLI imports it. - Add to
sync-workspace.test.ts:- In a dry run, a function policy is never called and both conflicting projects report
skipped-conflict. - A
.envfile withDEEPNOTE_TOKENinrootDirleavesprocess.envunchanged. - A started file replacement completes after cancellation: with
allFiles: trueandconcurrency: 2, project A changed on both sides and project B pushing new working filesone.csvandtwo.csv, hold B's DELETE ofone.csvuntil A's function policy rejects.syncWorkspacerejects with the policy's error;one.csvis deleted and uploaded exactly once;two.csvis neither; the saved manifest listsone.csvin B'sfileswith nopendingFileUploads. concurrencyvalues0,-1,0.5andNaNreject withRangeError, with nofetchcall and norootDircreated.- With the diverged-file fixture,
allFiles: trueand a function policy, the function receives{ kind: 'working-files-changed', projectId: 'p1', projectName: 'Alpha', projectDir: 'Alpha', files: [{ path: '_deepnote_static/index.html', reason: 'changed in Deepnote' }] }.
- In a dry run, a function policy is never called and both conflicting projects report
- Create
packages/cli/src/commands/sync.test.ts. Mock onlysyncWorkspacefrom@deepnote/cloud-sync, with avi.fnthat drivesonConflictandonEvent. Write expected output as literals, never produced byrenderHumanSummaryoroutputJson. Cover:- Policy:
skipandoverridepass through.askwith TTYs passes a function that, for each of the five conflict kinds, callsselectwith today's literal question and override label and returns the answer.askwithout a TTY, andaskwith TTYs and-o json, pass'skip'and print theNo interactive terminaldebug line.askwith TTYs in a dry run passes a function and prints no debug line. - Events: the
listing-projectsandproject-outcomelines print without-oand are suppressed with-o json; awarningreaches stderr verbatim. - Arguments: every flag reaches
syncWorkspace, withdirasrootDirand--urlasbaseUrl. A token in<root>/.envis used, and--tokenoverrides it. createSyncAction: with no token anywhere it fails with exit code 2 and a message startingMissing authentication token., without callingsyncWorkspace.-o jsonprints the result once as 2-space-indented JSON and no summary. Without-o, the summary, including the dry-run prefix and the untracked-files line, prints exactly as today. An unsuccessful result setsprocess.exitCodeto 1 and the handler resolves; a successful one leaves it unset. AnExitPromptErrorfromselectfails with exit code 1 and the prompt's message.
- Policy:
- Move
- Document the API
- In
packages/cloud-sync/README.md, add asyncWorkspacesection with the option, event and conflict tables and the cancellation contract: a rejecting policy stops the run at its next checkpoints, so no new project or write starts; steps already under way (including a started file replacement) finish; a final manifest save is attempted unless it is a dry run; and the run then rejects with the same error, with no engine work still running. The option table states thatconcurrencymust be a positive integer and that any other value rejects withRangeError. The conflict table describesprojectDirandfiles[].pathwith the same wording as step 1. - Remove
planProjectPaths,pathsOverlap,PlannedProjectPaths,sha256,baselineDiverged,assertNoSymbolicLinkAncestors,saveSyncManifest,isSafeRelativeFilePathandprojectFilesDirfrompackages/cloud-sync/src/index.tswhengrepfinds no importer of them outsidepackages/cloud-sync.
- In
Follow-ups
- Single-project sync (Get deepnote sync and publish ready for the editor and agents #543). Adds
projectIdstosyncWorkspaceanddeepnote sync --project <id|path>. Depends on this issue. - MCP sync tool (Add sync, publish and app tools to the local MCP server #544). Adds
deepnote_syncto@deepnote/mcp, built onsyncWorkspace. Depends on this issue.

Activity
feat(cloud-sync): move the workspace sync engine into @deepnote/cloud…