feat(cli): deepnote sync — mirror workspace projects with the local filesystem (GRN-5421) - #429
feat(cli): deepnote sync — mirror workspace projects with the local filesystem (GRN-5421)#429voyti wants to merge 2 commits into
Conversation
📝 WalkthroughWalkthroughAdds bidirectional Estimated code review effort: 4 (Complex) | ~60 minutes Suggested reviewers: Sequence Diagram(s)sequenceDiagram
participant CLI
participant SyncWorkspace
participant DeepnoteCloud
participant LocalWorkspace
CLI->>SyncWorkspace: execute sync options
SyncWorkspace->>DeepnoteCloud: list projects and export YAML
DeepnoteCloud-->>SyncWorkspace: project data
SyncWorkspace->>LocalWorkspace: compare hashes and manifest records
SyncWorkspace->>DeepnoteCloud: import changed YAML with concurrency fields
DeepnoteCloud-->>SyncWorkspace: canonical export or conflict
SyncWorkspace->>LocalWorkspace: update files and manifest
SyncWorkspace-->>CLI: report outcomes and exit status
🚥 Pre-merge checks | ✅ 5 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (5 passed)
Comment |
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #429 +/- ##
==========================================
+ Coverage 87.36% 87.48% +0.12%
==========================================
Files 181 185 +4
Lines 9494 9953 +459
Branches 2624 2768 +144
==========================================
+ Hits 8294 8707 +413
- Misses 1199 1244 +45
- Partials 1 2 +1 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (1)
packages/cli/src/utils/sync-manifest.ts (1)
94-106: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick winUse code-unit ordering here.
localeCompare()without an explicit locale can sort differently across environments, so the manifest can churn between machines.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/cli/src/utils/sync-manifest.ts` around lines 94 - 106, Replace the locale-dependent comparators in the sortedProjects project and record.files ordering with deterministic code-unit comparisons, preserving the existing ascending key order and manifest structure.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@packages/cli/src/utils/sync-paths.ts`:
- Around line 26-33: Update the cleaning chain for name so .slice(0,
MAX_SEGMENT_LENGTH) runs before .replace(/[. ]+$/, ''). Keep the existing
normalization, illegal-character replacement, trimming, and length limit while
ensuring the final segment cannot end in a dot or space.
In `@skills/deepnote/references/cli-sync.md`:
- Line 47: Update the inline code span describing the deterministic
path-collision suffix so the leading space is outside the backticks, preserving
the displayed ` (<short id>)` meaning while satisfying MD038.
---
Nitpick comments:
In `@packages/cli/src/utils/sync-manifest.ts`:
- Around line 94-106: Replace the locale-dependent comparators in the
sortedProjects project and record.files ordering with deterministic code-unit
comparisons, preserving the existing ascending key order and manifest structure.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 3c2dc592-b92f-4c59-9c6b-37299d83c850
📒 Files selected for processing (15)
cspell.jsonpackages/cli/README.mdpackages/cli/src/cli.tspackages/cli/src/commands/sync.test.tspackages/cli/src/commands/sync.tspackages/cli/src/utils/sync-manifest.test.tspackages/cli/src/utils/sync-manifest.tspackages/cli/src/utils/sync-paths.test.tspackages/cli/src/utils/sync-paths.tspackages/cloud/README.mdpackages/cloud/src/index.tspackages/cloud/src/sync.test.tspackages/cloud/src/sync.tsskills/deepnote/SKILL.mdskills/deepnote/references/cli-sync.md
| const cleaned = name | ||
| .normalize('NFC') | ||
| .replace(ILLEGAL_CHARACTERS, '_') | ||
| .trim() | ||
| // Windows silently strips trailing dots and spaces, which would desync the manifest's idea of | ||
| // the path from what the filesystem actually created. | ||
| .replace(/[. ]+$/, '') | ||
| .slice(0, MAX_SEGMENT_LENGTH) |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
slice after the trailing-strip can re-add a trailing dot/space.
Truncating at MAX_SEGMENT_LENGTH runs after .replace(/[. ]+$/, ''), so a name longer than the limit that lands a . or space on the boundary keeps it — the Windows silent-strip this guards against, desyncing the manifest path from the file actually created. Strip after slicing.
🐛 Reorder slice before the trailing strip
const cleaned = name
.normalize('NFC')
.replace(ILLEGAL_CHARACTERS, '_')
.trim()
+ .slice(0, MAX_SEGMENT_LENGTH)
// Windows silently strips trailing dots and spaces, which would desync the manifest's idea of
// the path from what the filesystem actually created.
.replace(/[. ]+$/, '')
- .slice(0, MAX_SEGMENT_LENGTH)📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const cleaned = name | |
| .normalize('NFC') | |
| .replace(ILLEGAL_CHARACTERS, '_') | |
| .trim() | |
| // Windows silently strips trailing dots and spaces, which would desync the manifest's idea of | |
| // the path from what the filesystem actually created. | |
| .replace(/[. ]+$/, '') | |
| .slice(0, MAX_SEGMENT_LENGTH) | |
| const cleaned = name | |
| .normalize('NFC') | |
| .replace(ILLEGAL_CHARACTERS, '_') | |
| .trim() | |
| .slice(0, MAX_SEGMENT_LENGTH) | |
| // Windows silently strips trailing dots and spaces, which would desync the manifest's idea of | |
| // the path from what the filesystem actually created. | |
| .replace(/[. ]+$/, '') |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/cli/src/utils/sync-paths.ts` around lines 26 - 33, Update the
cleaning chain for name so .slice(0, MAX_SEGMENT_LENGTH) runs before
.replace(/[. ]+$/, ''). Keep the existing normalization, illegal-character
replacement, trimming, and length limit while ensuring the final segment cannot
end in a dot or space.
| State lives in `.deepnote-sync.json` in the synced directory: a map of project id → local path, | ||
| last-synced `metadata.modifiedAt`, and a content hash. Projects are tracked by id because names | ||
| (projects and folders) are **not unique** in Deepnote — cloud renames become local file moves, and | ||
| path collisions get a deterministic ` (<short id>)` suffix. |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
MD038: leading space inside the code span. Move the space out so lint passes without losing meaning.
✏️ Fix
-path collisions get a deterministic ` (<short id>)` suffix.
+path collisions get a deterministic `(<short id>)` suffix (preceded by a space).📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| path collisions get a deterministic ` (<short id>)` suffix. | |
| path collisions get a deterministic `(<short id>)` suffix (preceded by a space). |
🧰 Tools
🪛 markdownlint-cli2 (0.23.0)
[warning] 47-47: Spaces inside code span elements
(MD038, no-space-in-code)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@skills/deepnote/references/cli-sync.md` at line 47, Update the inline code
span describing the deterministic path-collision suffix so the leading space is
outside the backticks, preserving the displayed ` (<short id>)` meaning while
satisfying MD038.
Source: Linters/SAST tools
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@packages/cli/src/commands/sync.test.ts`:
- Around line 298-304: Update the syncWorkspace invocation in the test “skips
pushing a no-notebook document under --delete-missing-notebooks instead of
wiping the project” to explicitly pass onConflict: 'skip' alongside the existing
baseOptions and deleteMissingNotebooks settings, ensuring the test does not
depend on TTY behavior.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 8b19b0ff-1cb8-4299-b54c-7bbb27776ed1
📒 Files selected for processing (6)
packages/cli/src/commands/sync.test.tspackages/cli/src/commands/sync.tspackages/cloud/README.mdpackages/cloud/src/sync.test.tspackages/cloud/src/sync.tsskills/deepnote/references/cli-sync.md
🚧 Files skipped from review as they are similar to previous changes (5)
- skills/deepnote/references/cli-sync.md
- packages/cloud/src/sync.ts
- packages/cloud/README.md
- packages/cloud/src/sync.test.ts
- packages/cli/src/commands/sync.ts
…cal filesystem (GRN-5421)
…etes Aligns the CLI with the server sync API's latest review rounds: - pushes now send baseContentHash (the manifest's sha256 of the base export bytes) alongside baseModifiedAt - the timestamp only sees structural changes, so editor block edits made after the base export were previously overwritten without a 409; the content hash closes that lost-update gap (and applies even when metadata.modifiedAt could not be read, where the timestamp check silently disarmed) - the import API now accepts a no-notebook document (a no-op, or a delete-every-notebook under deleteMissingNotebooks); since an empty local file is more often an accident than an intent to wipe the project, the destructive combination is confirmed like a conflict (ask prompts, override proceeds, skip - and no-TTY ask - skips) - capture project.modifiedAt from the import response (the post-import fingerprint, usable to chain pushes) and document the import's 413 and naming-rule 422 responses
57246ca to
15d5b33
Compare
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (2)
packages/cli/src/commands/sync.ts (1)
432-440: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueDry run mutates the manifest on
noopbut not onpull. Harmless today becausesaveSyncManifestis skipped, but it makes the in-memory manifest state depend on the branch. Consider gating thenoopcommit on!ctx.dryRuntoo.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/cli/src/commands/sync.ts` around lines 432 - 440, Gate the commitRecord call in the step === 'noop' branch on !ctx.dryRun, matching the existing behavior in the step === 'pull' branch. Keep the unchanged outcome construction and non-dry-run commit behavior intact.packages/cli/src/utils/sync-manifest.ts (1)
94-108: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win
localeCompareundercuts the determinism goal. Its ordering depends on the runtime's locale/ICU data, so the same manifest can serialize differently on two machines — exactly the git churn the sorting exists to prevent. Use a plain codepoint comparison.Also: the write is not atomic. An interrupted
fs.writeFileleaves a truncated manifest, whichloadSyncManifestthen rejects outright, forcing a full re-sync. Write to a temp file andrename.♻️ Proposed change
+const byKey = ([a]: [string, unknown], [b]: [string, unknown]): number => (a < b ? -1 : a > b ? 1 : 0) + export async function saveSyncManifest(rootDir: string, manifest: SyncManifest): Promise<void> { const sortedProjects = Object.fromEntries( Object.entries(manifest.projects) - .sort(([a], [b]) => a.localeCompare(b)) + .sort(byKey) .map(([id, record]) => [ id, { ...record, ...(record.files - ? { files: Object.fromEntries(Object.entries(record.files).sort(([a], [b]) => a.localeCompare(b))) } + ? { files: Object.fromEntries(Object.entries(record.files).sort(byKey)) } : {}), }, ]) ) const content = `${JSON.stringify({ version: manifest.version, projects: sortedProjects }, null, 2)}\n` - await fs.writeFile(path.join(rootDir, SYNC_MANIFEST_FILENAME), content, 'utf-8') + const target = path.join(rootDir, SYNC_MANIFEST_FILENAME) + const temp = `${target}.tmp` + await fs.writeFile(temp, content, 'utf-8') + await fs.rename(temp, target) }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/cli/src/utils/sync-manifest.ts` around lines 94 - 108, Update the sorting in the manifest serialization flow to use a locale-independent plain codepoint comparator for both project IDs and file paths instead of localeCompare. In the write flow, stage the serialized content in a temporary file and rename it to SYNC_MANIFEST_FILENAME only after the write completes, using the existing sync-manifest symbols and preserving the final manifest format.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@packages/cli/src/commands/sync.ts`:
- Around line 227-230: Validate manifest record paths with
isSafeRelativeFilePath before using them in the prune deletion flow, including
the logic around toAbsolute and the .deepnote/.files replacement near
record.path. Skip or reject unsafe paths before any fs.rm call, and ensure
non-.deepnote paths cannot cause recursive deletion of the raw manifest path
outside ctx.rootDir.
In `@skills/deepnote/references/cli-sync.md`:
- Line 21: Remove the unsupported `llm` value from the output-format
documentation for the sync command in `skills/deepnote/references/cli-sync.md`,
leaving only `json` unless the implementation is explicitly expanded to support
`llm`.
---
Nitpick comments:
In `@packages/cli/src/commands/sync.ts`:
- Around line 432-440: Gate the commitRecord call in the step === 'noop' branch
on !ctx.dryRun, matching the existing behavior in the step === 'pull' branch.
Keep the unchanged outcome construction and non-dry-run commit behavior intact.
In `@packages/cli/src/utils/sync-manifest.ts`:
- Around line 94-108: Update the sorting in the manifest serialization flow to
use a locale-independent plain codepoint comparator for both project IDs and
file paths instead of localeCompare. In the write flow, stage the serialized
content in a temporary file and rename it to SYNC_MANIFEST_FILENAME only after
the write completes, using the existing sync-manifest symbols and preserving the
final manifest format.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 7aa5132e-8320-485b-90f2-340bf3a9d9ad
📒 Files selected for processing (15)
cspell.jsonpackages/cli/README.mdpackages/cli/src/cli.tspackages/cli/src/commands/sync.test.tspackages/cli/src/commands/sync.tspackages/cli/src/utils/sync-manifest.test.tspackages/cli/src/utils/sync-manifest.tspackages/cli/src/utils/sync-paths.test.tspackages/cli/src/utils/sync-paths.tspackages/cloud/README.mdpackages/cloud/src/index.tspackages/cloud/src/sync.test.tspackages/cloud/src/sync.tsskills/deepnote/SKILL.mdskills/deepnote/references/cli-sync.md
🚧 Files skipped from review as they are similar to previous changes (8)
- packages/cloud/src/index.ts
- cspell.json
- packages/cli/src/utils/sync-paths.ts
- packages/cli/src/cli.ts
- packages/cloud/src/sync.test.ts
- packages/cli/src/utils/sync-paths.test.ts
- packages/cloud/src/sync.ts
- packages/cli/README.md
| /** Join a manifest-style POSIX relative path onto the sync root for filesystem access. */ | ||
| function toAbsolute(ctx: SyncContext, relativePath: string): string { | ||
| return path.join(ctx.rootDir, ...relativePath.split('/')) | ||
| } |
There was a problem hiding this comment.
🔒 Security & Privacy | 🟠 Major | ⚡ Quick win
Validate manifest paths before deleting with them. record.path is only typed as string by the manifest schema, and toAbsolute happily joins ../.. segments. A hand-edited or committed-and-cloned .deepnote-sync.json can therefore point --prune at a recursive fs.rm outside the sync root. syncProjectFiles already guards inventory paths with isSafeRelativeFilePath; the manifest path deserves the same treatment. Also note line 587: if record.path does not end in .deepnote, the .files replace is a no-op and the recursive delete targets record.path itself.
🛡️ Proposed guard
function toAbsolute(ctx: SyncContext, relativePath: string): string {
+ if (!isSafeRelativeFilePath(relativePath)) {
+ throw new Error(`Unsafe path in the sync manifest: ${relativePath}`)
+ }
return path.join(ctx.rootDir, ...relativePath.split('/'))
} if (ctx.options.prune) {
if (!ctx.dryRun) {
await fs.rm(toAbsolute(ctx, record.path), { force: true })
- await fs.rm(toAbsolute(ctx, record.path.replace(/\.deepnote$/, '.files')), { recursive: true, force: true })
+ if (record.path.endsWith('.deepnote')) {
+ await fs.rm(toAbsolute(ctx, record.path.replace(/\.deepnote$/, '.files')), { recursive: true, force: true })
+ }
delete manifest.projects[projectId]
}Also applies to: 583-589
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/cli/src/commands/sync.ts` around lines 227 - 230, Validate manifest
record paths with isSafeRelativeFilePath before using them in the prune deletion
flow, including the logic around toAbsolute and the .deepnote/.files replacement
near record.path. Skip or reject unsafe paths before any fs.rm call, and ensure
non-.deepnote paths cannot cause recursive deletion of the raw manifest path
outside ctx.rootDir.
| | `--delete-missing-notebooks` | When pushing, delete cloud notebooks removed from the local file | | ||
| | `--prune` | Delete local files for projects/files that no longer exist in the cloud | | ||
| | `--dry-run` | Show what would be synced without writing anything | | ||
| | `-o, --output <format>` | Output format: `json`, `llm` | |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
llm is not a supported output format here. SyncOptions.output is typed 'json' and createSyncAction only branches on 'json', falling through to the human summary otherwise — while still suppressing progress lines. Drop llm from the doc (or implement it).
✏️ Fix
-| `-o, --output <format>` | Output format: `json`, `llm` |
+| `-o, --output <format>` | Output format: `json` |As per coding guidelines, "When changing the .deepnote file format, CLI commands, or MCP tools, update the corresponding reference files under skills/deepnote/."
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| | `-o, --output <format>` | Output format: `json`, `llm` | | |
| | `-o, --output <format>` | Output format: `json` | |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@skills/deepnote/references/cli-sync.md` at line 21, Remove the unsupported
`llm` value from the output-format documentation for the sync command in
`skills/deepnote/references/cli-sync.md`, leaving only `json` unless the
implementation is explicitly expanded to support `llm`.
Source: Path instructions
|
Superseded by #451 |
Implements the CLI half of GRN-5421 ("Sync deepnote.com projects with local filesystem (and git)"). The server half is deepnote-internal #20511 (export/import/download endpoints + folder info on the projects list).
What this adds
deepnote sync [dir]Clones the workspace into a local directory — every project becomes
<folder path>/<project name>.deepnote, following the workspace folder tree — and pushes local edits back on the next sync.Flags:
--all-files(download working-directory files, incremental),--on-conflict ask|skip|override,--delete-missing-notebooks,--prune,--dry-run,-o json, plus the usual--url/--token(DEEPNOTE_TOKEN).@deepnote/cloudadditions (src/sync.ts)listAllProjects(walks pagination to exhaustion — unlike #419's name-narrowedfindNotebookwalk, sync needs the complete list),getProjectDetail(file inventory),exportProject,importProject,downloadProjectFile. Same conventions as the existing modules: global fetch,ApiError, permissive zod schemas.Manifest design
Sync state lives in
.deepnote-sync.jsonat the sync root (plain JSON, sorted keys, safe to commit). Per project id:path— local file path. Identity is always the project id, never a name: neither project nor folder names are unique in Deepnote. Cloud renames become local file moves; two same-named folders merge into one local directory; path collisions (case-insensitive, so macOS/Windows are safe) are resolved deterministically by suffixing every collision-group member with a short id — a new project can never silently steal an existing project's clean path.modifiedAt— the export'smetadata.modifiedAtat last sync; sent back asbaseModifiedAton every push (lost-update protection).contentHash— SHA-256 of the last-synced bytes. The server export is deterministic, so comparing the local file and a fresh export against this hash cleanly separates local edit / cloud edit / both with no clocks involved.files— per-pathsize/updatedAtfor incremental--all-filesdownloads (into<name>.files/beside the.deepnotefile; download-only, no upload).Conflict UX
POST /importwithbaseModifiedAt). A 409 means the cloud moved concurrently → prompt: overwrite cloud (force=trueretry) or skip.--on-conflict skip|overrideanswers up front;askdegrades to skip when there's no TTY (cron/CI,-o json), so nothing ever hangs on a prompt.--all-filesdownloads). Per-project errors don't abort the rest of the workspace; exit 1 if any project failed.Explicitly noted
requirements.txtprecedence: the server never applies project name, integrations, orsettings.requirementsfrom a pushed document —requirements.txtin the project files is the source of truth,settings.requirementsis a lossy projection. After every successful push the local file is rewritten from a fresh export so those never-applied fields (and server-assigned notebook ids) don't drift..deepnotefiles are reported and left alone), and never deletes local files without--prune.Testing
packages/cloud/src/sync.test.ts— endpoint contracts, pagination walk + runaway guard, error mapping (409 carries status for the conflict flow).packages/cli/src/utils/sync-paths.test.ts— sanitization (Windows reserved names, control chars, trailing dots), deterministic collision handling, traversal-safe inventory paths.packages/cli/src/utils/sync-manifest.test.ts— round-trip, corrupt-manifest rejection, byte-stable output.packages/cli/src/commands/sync.test.ts— end-to-end against a mocked API + real temp dirs: first pull, no-op re-sync, pull, push (assertsbaseModifiedAt+ canonical re-export write-back), 409 skip/override, both-sides conflict,--all-filesincremental download + unsafe-path skip,--prune, rename→move, untracked files, dry-run, per-project error isolation.All repo checks green:
lintAndFormat,typecheck,test(2684 passed),spell-check,build.Remaining before un-draft
ra-20511.deepnote-staging.com(#20511 branch): pull of a 58-project workspace with folder tree → no-op re-pull → local edit → push (notebooksoverwritten/created, canonical re-export written back) → no-op re-pull. Also verified live: both-sides conflict skip +--on-conflict override,--all-filesincremental download (byte-correct), cloud-deletion kept-then---prune, and per-project error isolation on a malformed local document.🤖 Generated with Claude Code
Summary by CodeRabbit
Summary by CodeRabbit
New Features
deepnote sync [dir]command to mirror cloud projects locally and sync local changes back.ask,skip,override),--dry-run,--prune, and--all-files, including machine-readable output..deepnote-sync.json.Documentation
Bug Fixes
--delete-missing-notebooksis used with projects whose local document contains no notebooks.Tests / Chores