├── README.md └── setup_roam_extension.py /README.md: -------------------------------------------------------------------------------- 1 | # Roam Extension Setup Script 2 | 3 | This Python script automates creating and updating a Roam Research extension in Roam Depot. It: 4 | 5 | 1. **Creates** a new local Git repository for your extension (on the `master` branch). 6 | 2. **Publishes** it to GitHub (using GitHub CLI). 7 | 3. **Forks** the [roam-depot repository](https://github.com/Roam-Research/roam-depot) if needed. 8 | 4. **Adds** your extension’s metadata JSON file to your `roam-depot` fork. 9 | 5. **Opens** a Pull Request (PR) from your `master` branch to Roam’s `main` branch. 10 | 6. **Provides** an update mechanism so you can modify your extension’s code and update the same PR with a single command. 11 | 12 | ## Prerequisites 13 | 14 | 1. **Git** installed. 15 | 2. **Python 3** installed. 16 | 3. [**GitHub CLI**](https://cli.github.com/) installed and authenticated: 17 | 18 | ```bash 19 | gh auth login 20 | ``` 21 | 4. A GitHub account. 22 | 23 | ## Usage 24 | 25 | 1. **Clone or copy** this script locally, then make it executable (on macOS/Linux): 26 | 27 | ```bash 28 | chmod +x setup_roam_extension.py 29 | ``` 30 | 31 | 2. **Submit** your extension for the first time by running: 32 | 33 | ```bash 34 | python setup_roam_extension.py submit 35 | ``` 36 | 37 | - You’ll be prompted for required details such as repository name, extension name, description, etc. 38 | - **GitHub username** is **auto-detected** via `gh api user -q .login`. 39 | - A local folder named after your extension repository will be created. 40 | - The new GitHub repo will be initialized on the `master` branch and pushed to GitHub. 41 | - A metadata JSON file will be added to your fork of `roam-depot`. 42 | - A PR to the official `roam-depot` is opened automatically. 43 | 44 | 3. **Edit** your extension code (`extension.js`, etc.) as needed. 45 | 46 | 4. **Update** an existing PR after making changes: 47 | 48 | ```bash 49 | python setup_roam_extension.py update --extension-repo-name "my-roam-extension" 50 | ``` 51 | 52 | - This will commit and push new code to `master`. 53 | - It will update the **`source_commit`** in your metadata JSON to point to the latest commit. 54 | - This automatically updates the **same** Pull Request, so no need to open a new one. 55 | 56 | 5. **Resume** after failures: 57 | - The script uses a _checkpoint file_ (`roam_extension_setup_state.json`) to track progress. 58 | - If a command fails (e.g., a branch mismatch), fix the issue, re-run `submit`, and the script will continue from the last successful stage. 59 | 60 | 6. **Reset** and start from scratch: 61 | 62 | ```bash 63 | python setup_roam_extension.py submit --reset 64 | ``` 65 | - This removes the checkpoint so the script starts at stage 0 again. 66 | 67 | ## Example Commands 68 | 69 | ### First-time Submit 70 | 71 | ```bash 72 | python setup_roam_extension.py submit 73 | --extension-repo-name "roam-new-daypage-block" 74 | --extension-name "Create a new Block on a Daily page Roam Shortcut" 75 | --extension-short-description "Introduces a shortcut to create a new block on the current day’s page and open it in a sidebar." 76 | --extension-author "John Doe" 77 | ``` 78 | 79 | ### Update an Existing Extension 80 | 81 | ```bash 82 | python setup_roam_extension.py update 83 | --extension-repo-name "roam-new-daypage-block" 84 | ``` 85 | 86 | After your Pull Request is merged by the Roam team, your extension should appear in the Roam Marketplace! 87 | -------------------------------------------------------------------------------- /setup_roam_extension.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import os 4 | import subprocess 5 | import sys 6 | import shutil 7 | import json 8 | import argparse 9 | 10 | CHECKPOINT_FILE = "roam_extension_setup_state.json" 11 | 12 | def run_cmd(cmd, cwd=None): 13 | """ 14 | Helper function to run shell commands with error handling. 15 | """ 16 | print(f"\nRunning command: {cmd}") 17 | try: 18 | subprocess.run(cmd, shell=True, check=True, cwd=cwd) 19 | except subprocess.CalledProcessError as e: 20 | print(f"\nERROR: Command failed -> {cmd}") 21 | print(f"{e}") 22 | sys.exit(1) 23 | 24 | def load_checkpoint(): 25 | """ 26 | Load the current stage checkpoint from file. 27 | Returns 0 if no checkpoint is found or if file is unreadable. 28 | """ 29 | if not os.path.isfile(CHECKPOINT_FILE): 30 | return 0 31 | try: 32 | with open(CHECKPOINT_FILE, "r") as f: 33 | data = json.load(f) 34 | return data.get("last_completed_stage", 0) 35 | except: 36 | return 0 37 | 38 | def save_checkpoint(stage_number): 39 | """ 40 | Save the last completed stage to the checkpoint file. 41 | """ 42 | data = {"last_completed_stage": stage_number} 43 | with open(CHECKPOINT_FILE, "w") as f: 44 | json.dump(data, f) 45 | 46 | # ------------------------------------------------------------------------------ 47 | # SUB-COMMAND: SUBMIT 48 | # Creates a brand-new extension repo, forks roam-depot, publishes a new PR, 49 | # using 'master' as the default branch in your extension repo. 50 | # ------------------------------------------------------------------------------ 51 | 52 | def stage_1_init_local_repo(args): 53 | print("\n=== Stage 1: Initialize local repository and create necessary files ===") 54 | 55 | # Create local folder if needed 56 | if not os.path.exists(args.extension_repo_name): 57 | os.makedirs(args.extension_repo_name) 58 | 59 | os.chdir(args.extension_repo_name) 60 | 61 | # Create extension.js either from file or from code 62 | if args.extension_file_path: 63 | if not os.path.isfile(args.extension_file_path): 64 | print(f"\nERROR: File '{args.extension_file_path}' does not exist.") 65 | sys.exit(1) 66 | print(f"Copying {args.extension_file_path} to extension.js") 67 | shutil.copyfile(args.extension_file_path, "extension.js") 68 | else: 69 | extension_js_code = args.extension_js_code.strip() 70 | if not extension_js_code: 71 | # Provide a default minimal extension.js if none is pasted 72 | extension_js_code = """export default { 73 | onload: () => { console.log("Extension loaded!"); }, 74 | onunload: () => { console.log("Extension unloaded!"); } 75 | };""" 76 | with open("extension.js", "w") as f: 77 | f.write(extension_js_code + "\n") 78 | 79 | # Create a minimal README.md if needed 80 | if not os.path.isfile("README.md"): 81 | with open("README.md", "w") as f: 82 | f.write(f"# {args.extension_name}\n\n{args.extension_short_description}\n\n") 83 | f.write("## Installation\n\n") 84 | f.write("1. Go to Roam Marketplace\n") 85 | f.write(f"2. Search for \"{args.extension_name}\"\n") 86 | f.write("3. Install\n\n") 87 | f.write("## Usage\n\n```javascript\n") 88 | f.write("// This extension adds ...\n") 89 | f.write("```\n") 90 | 91 | # Initialize local git repo 92 | run_cmd("git init") 93 | 94 | # Force-checkout branch "master" 95 | run_cmd("git checkout -b master") 96 | 97 | run_cmd("git add .") 98 | run_cmd('git commit -m "Initial commit of extension files"') 99 | 100 | def stage_2_create_github_repo(args): 101 | print("\n=== Stage 2: Create the GitHub repo and push ===") 102 | 103 | # Build the description 104 | extension_description = f"{args.extension_short_description} (by {args.extension_author})" 105 | 106 | # Create a public GitHub repo 107 | create_repo_cmd = ( 108 | f'gh repo create {args.extension_repo_name} ' 109 | f'--public ' 110 | f'--description "{extension_description}" ' 111 | f'--source . ' 112 | f'--remote upstream' 113 | ) 114 | run_cmd(create_repo_cmd) 115 | 116 | # Now push to 'master' 117 | run_cmd("git push -u upstream master") 118 | 119 | def stage_3_fork_roam_depot(): 120 | print("\n=== Stage 3: Fork roam-depot ===") 121 | run_cmd("gh repo fork Roam-Research/roam-depot --remote=false --clone=false || true") 122 | 123 | def stage_4_clone_fork(args): 124 | print("\n=== Stage 4: Clone your roam-depot fork locally ===") 125 | os.chdir("..") # step out of extension repo 126 | 127 | if not os.path.exists(args.depot_folder): 128 | clone_cmd = f"gh repo clone {args.github_username}/roam-depot {args.depot_folder}" 129 | run_cmd(clone_cmd) 130 | 131 | def stage_5_create_metadata_file(args): 132 | print("\n=== Stage 5: Create extension metadata file in roam-depot fork ===") 133 | 134 | # Capture current commit of the extension 135 | os.chdir(f"./{args.extension_repo_name}") 136 | source_commit = subprocess.check_output(["git", "rev-parse", "HEAD"]).decode("utf-8").strip() 137 | 138 | # Go to roam-depot 139 | os.chdir(f"../{args.depot_folder}") 140 | 141 | metadata_dir = f"extensions/{args.github_username}" 142 | os.makedirs(metadata_dir, exist_ok=True) 143 | metadata_file = f"{metadata_dir}/{args.extension_repo_name}.json" 144 | 145 | # Build tags array 146 | if args.extension_tags: 147 | tags_list = [t.strip() for t in args.extension_tags.split(",") if t.strip()] 148 | tags_json = "[" + ", ".join(f'"{t}"' for t in tags_list) + "]" 149 | else: 150 | tags_json = "[]" 151 | 152 | # Build JSON lines 153 | lines = [ 154 | "{", 155 | f' "name": "{args.extension_name}",', 156 | f' "short_description": "{args.extension_short_description}",', 157 | f' "author": "{args.extension_author}",', 158 | f' "tags": {tags_json},', 159 | f' "source_url": "https://github.com/{args.github_username}/{args.extension_repo_name}",', 160 | f' "source_repo": "https://github.com/{args.github_username}/{args.extension_repo_name}.git",', 161 | f' "source_commit": "{source_commit}"' 162 | ] 163 | if args.stripe_account: 164 | lines.append(f' ,"stripe_account": "{args.stripe_account}"') 165 | lines.append("}") 166 | 167 | with open(metadata_file, "w") as f: 168 | f.write("\n".join(lines) + "\n") 169 | 170 | run_cmd(f'git add "{metadata_file}"') 171 | run_cmd(f'git commit -m "Add {args.extension_repo_name} metadata for Roam extension"') 172 | run_cmd("git push") 173 | 174 | def stage_6_create_pr(args): 175 | print("\n=== Stage 6: Create Pull Request to Roam-Research/roam-depot ===") 176 | 177 | pr_title = f"Add {args.extension_name} extension" 178 | pr_body = ( 179 | f"This PR adds a new extension [{args.extension_name}](https://github.com/{args.github_username}/{args.extension_repo_name})." 180 | ) 181 | # Note: We base on 'main' in roam-depot, but head is your 'master' 182 | create_pr_cmd = ( 183 | f'gh pr create ' 184 | f'--title "{pr_title}" ' 185 | f'--body "{pr_body}" ' 186 | f'--base main ' 187 | f'--head "{args.github_username}:master" ' 188 | f'--repo Roam-Research/roam-depot' 189 | ) 190 | run_cmd(create_pr_cmd) 191 | 192 | def command_submit(args): 193 | """ 194 | SUBCOMMAND: 'submit' 195 | Executes the multi-stage flow to create a new extension PR using 'master' as your repo's branch. 196 | """ 197 | if args.reset and os.path.isfile(CHECKPOINT_FILE): 198 | print("Resetting the checkpoint. Starting from stage 0.") 199 | os.remove(CHECKPOINT_FILE) 200 | 201 | current_stage = load_checkpoint() 202 | 203 | # === STAGE 1 === 204 | if current_stage < 1: 205 | stage_1_init_local_repo(args) 206 | save_checkpoint(1) 207 | current_stage = 1 208 | else: 209 | print("\nSkipping Stage 1 (already completed).") 210 | 211 | # === STAGE 2 === 212 | if current_stage < 2: 213 | stage_2_create_github_repo(args) 214 | save_checkpoint(2) 215 | current_stage = 2 216 | else: 217 | print("\nSkipping Stage 2 (already completed).") 218 | 219 | # === STAGE 3 === 220 | if current_stage < 3: 221 | stage_3_fork_roam_depot() 222 | save_checkpoint(3) 223 | current_stage = 3 224 | else: 225 | print("\nSkipping Stage 3 (already completed).") 226 | 227 | # === STAGE 4 === 228 | if current_stage < 4: 229 | stage_4_clone_fork(args) 230 | save_checkpoint(4) 231 | current_stage = 4 232 | else: 233 | print("\nSkipping Stage 4 (already completed).") 234 | 235 | # === STAGE 5 === 236 | if current_stage < 5: 237 | stage_5_create_metadata_file(args) 238 | save_checkpoint(5) 239 | current_stage = 5 240 | else: 241 | print("\nSkipping Stage 5 (already completed).") 242 | 243 | # === STAGE 6 === 244 | if current_stage < 6: 245 | stage_6_create_pr(args) 246 | save_checkpoint(6) 247 | current_stage = 6 248 | else: 249 | print("\nSkipping Stage 6 (already completed).") 250 | 251 | print("\nAll stages completed successfully!") 252 | print("Your Pull Request should now be open. Once merged, your extension will appear in the Marketplace.") 253 | 254 | # ------------------------------------------------------------------------------ 255 | # SUB-COMMAND: UPDATE 256 | # Commits/pushes new code changes in extension repo, updates commit hash in 257 | # roam-depot fork, and pushes to the same branch to update the open PR. 258 | # (Your extension uses 'master'.) 259 | # ------------------------------------------------------------------------------ 260 | 261 | def update_extension_code(args): 262 | """ 263 | 1. Go to extension repo, commit & push any new changes (to 'master'). 264 | 2. Grab new commit hash. 265 | 3. Update roam-depot fork’s metadata JSON, push changes (still to your fork's 'master'). 266 | """ 267 | repo_dir = args.extension_repo_name 268 | if not os.path.isdir(repo_dir): 269 | print(f"\nERROR: Directory '{repo_dir}' not found. Have you run 'submit' first?") 270 | sys.exit(1) 271 | 272 | # 1. Commit & push new changes in extension repo 273 | os.chdir(repo_dir) 274 | 275 | run_cmd("git add .") 276 | try: 277 | run_cmd('git commit -m "Update extension code"') 278 | except SystemExit: 279 | print("No new code changes found. Skipping commit.") 280 | 281 | run_cmd("git push -u upstream master") 282 | 283 | # 2. Get the new commit hash 284 | new_commit = subprocess.check_output(["git", "rev-parse", "HEAD"]).decode("utf-8").strip() 285 | 286 | # 3. Update roam-depot fork 287 | os.chdir("..") 288 | depot_folder = args.depot_folder 289 | if not os.path.isdir(depot_folder): 290 | print(f"\nERROR: Directory '{depot_folder}' not found. Did you run 'submit' to clone your fork?") 291 | sys.exit(1) 292 | 293 | os.chdir(depot_folder) 294 | run_cmd("git pull") # pull latest from your fork’s 'master' 295 | 296 | # Locate metadata file 297 | metadata_dir = f"extensions/{args.github_username}" 298 | metadata_file = f"{metadata_dir}/{args.extension_repo_name}.json" 299 | 300 | if not os.path.isfile(metadata_file): 301 | print(f"\nERROR: Metadata file '{metadata_file}' not found. Did you run 'submit' first?") 302 | sys.exit(1) 303 | 304 | # Update source_commit line 305 | with open(metadata_file, "r") as f: 306 | lines = f.readlines() 307 | 308 | new_lines = [] 309 | for line in lines: 310 | if '"source_commit"' in line: 311 | new_lines.append(f' "source_commit": "{new_commit}"\n') 312 | else: 313 | new_lines.append(line) 314 | 315 | with open(metadata_file, "w") as f: 316 | f.writelines(new_lines) 317 | 318 | run_cmd(f'git add "{metadata_file}"') 319 | run_cmd(f'git commit -m "Update source_commit to {new_commit}"') 320 | run_cmd("git push") 321 | 322 | print("\nUpdate complete! Your existing PR should now reflect the new commit hash.") 323 | 324 | # ------------------------------------------------------------------------------ 325 | # MAIN ENTRY POINT 326 | # ------------------------------------------------------------------------------ 327 | 328 | def main(): 329 | parser = argparse.ArgumentParser( 330 | description="Set up or update a Roam extension PR with a resumable multi-stage workflow. Uses 'master' as your extension repo's branch." 331 | ) 332 | 333 | subparsers = parser.add_subparsers(dest="command", required=True) 334 | 335 | # SUBCOMMAND: SUBMIT 336 | submit_parser = subparsers.add_parser("submit", help="Submit a brand-new extension PR to roam-depot, using 'master' branch.") 337 | submit_parser.add_argument("--reset", action="store_true", help="Reset the checkpoint and start from stage 0.") 338 | 339 | # Extension arguments 340 | submit_parser.add_argument("--extension-repo-name", help="Name of the new GitHub repo", required=False) 341 | submit_parser.add_argument("--extension-name", help="Human-readable name", required=False) 342 | submit_parser.add_argument("--extension-short-description", help="Short description", required=False) 343 | submit_parser.add_argument("--extension-author", help="Author name", required=False) 344 | submit_parser.add_argument("--extension-tags", default="", help="Comma-separated tags (e.g. 'test,print')") 345 | submit_parser.add_argument("--stripe-account", default="", help="Optional Stripe account ID.") 346 | submit_parser.add_argument("--extension-file-path", default="", help="Path to local extension.js file.") 347 | submit_parser.add_argument("--extension-js-code", default="", help="Inline extension.js code if no file is used.") 348 | submit_parser.add_argument("--depot-folder", default="roam-depot", help="Name of local fork folder for roam-depot.") 349 | 350 | # SUBCOMMAND: UPDATE 351 | update_parser = subparsers.add_parser("update", help="Update existing extension PR with new code changes. (Your repo uses 'master'.)") 352 | update_parser.add_argument("--extension-repo-name", help="Name of your extension's local folder/repo", required=True) 353 | update_parser.add_argument("--depot-folder", default="roam-depot", help="Local folder name of your roam-depot fork.") 354 | 355 | args = parser.parse_args() 356 | 357 | # AUTO-DETECT GITHUB USERNAME VIA GH CLI (needed for both 'submit' and 'update') 358 | try: 359 | detected_username = subprocess.check_output(["gh", "api", "user", "-q", ".login"]).decode().strip() 360 | except: 361 | print("ERROR: Could not detect your GitHub username via `gh api user -q .login`.") 362 | print("Make sure you are logged in: `gh auth login`.") 363 | sys.exit(1) 364 | 365 | setattr(args, "github_username", detected_username) 366 | print(f"Detected GitHub username: {args.github_username}") 367 | 368 | if args.command == "submit": 369 | # Prompt for missing fields 370 | if not args.extension_repo_name: 371 | args.extension_repo_name = input("Extension repository name (e.g. my-roam-extension): ").strip() 372 | if not args.extension_repo_name: 373 | print("ERROR: extension_repo_name is required.") 374 | sys.exit(1) 375 | 376 | if not args.extension_name: 377 | args.extension_name = input("Human-readable extension name (e.g. My Roam Extension): ").strip() 378 | if not args.extension_name: 379 | print("ERROR: extension_name is required.") 380 | sys.exit(1) 381 | 382 | if not args.extension_short_description: 383 | args.extension_short_description = input("Short description: ").strip() 384 | if not args.extension_short_description: 385 | print("ERROR: extension_short_description is required.") 386 | sys.exit(1) 387 | 388 | if not args.extension_author: 389 | args.extension_author = input("Author name: ").strip() 390 | if not args.extension_author: 391 | print("ERROR: extension_author is required.") 392 | sys.exit(1) 393 | 394 | if not args.extension_file_path and not args.extension_js_code: 395 | print("\nNo extension.js file or code provided. Paste your extension.js code below.") 396 | print("When finished, press Ctrl+D (macOS/Linux) or Ctrl+Z (Windows) on a new line, then Enter.\n") 397 | try: 398 | pasted_code = sys.stdin.read() 399 | except KeyboardInterrupt: 400 | print("\nCanceled by user.") 401 | sys.exit(0) 402 | if pasted_code.strip(): 403 | args.extension_js_code = pasted_code.strip() 404 | else: 405 | # Provide a default minimal extension.js if none is pasted 406 | args.extension_js_code = """export default { 407 | onload: () => { console.log("Extension loaded!"); }, 408 | onunload: () => { console.log("Extension unloaded!"); } 409 | };""" 410 | 411 | # Run the multi-stage submit flow 412 | command_submit(args) 413 | 414 | elif args.command == "update": 415 | # Run the update flow 416 | update_extension_code(args) 417 | 418 | if __name__ == "__main__": 419 | main() 420 | --------------------------------------------------------------------------------