#!/usr/bin/env python3 """Open a folder as a project: adopt it, remember it, build its pages. bin/office [--project ] [--session NAME] [--dry] bin/office . # the folder you are in Adopting writes one file, /.harness/harness.toml, and nothing else. An existing folder is never overwritten: what is already there stays, and only the missing file is written. Paperclip's project record is read, never created — bin/office adopts a project that exists, it does not invent one. The machine is remembered, so the flag is stated once. Running the same command on a machine that is not the recorded one is refused rather than silently producing a second copy of the same project. """ import json import os import subprocess import sys HERE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, os.path.join(HERE, "lib")) import harness as h # noqa: E402 import project as pj # noqa: E402 import tuios # noqa: E402 def parse(argv): opts = {"folder": ".", "project": "", "session": "", "dry": "--dry" in argv, "host": "", "no_switch": "--no-switch" in argv} args = [a for a in argv if not a.startswith("--")] if args: opts["folder"] = args[0] for i, a in enumerate(argv): if a == "--project" and i + 1 < len(argv): opts["project"] = argv[i + 1] if a == "--session" and i + 1 < len(argv): opts["session"] = argv[i + 1] if a == "--host" and i + 1 < len(argv): opts["host"] = argv[i + 1] return opts def ensure_session(session, folder): """Make the session exist. It cannot be shown from here, and that is fine. Moving a person's TUIOS client to another session is refused when the call comes from a pane — the daemon will not let a program change what the person is looking at. Creating the session and building its panes works, so the only step left for the person is switching to it, which TUIOS has a key for. """ if session in tuios.sessions(): h.say("session: %s (already there)" % session) return params = {"name": session, "cwd": folder, "window": False} out = subprocess.run(["tuios", "call", "new-session", json.dumps(params)], capture_output=True, text=True, timeout=60) if out.returncode != 0 and "session_exists" not in (out.stderr or out.stdout): raise h.Refused(3, "could not create session %r: %s" % (session, (out.stderr or out.stdout).strip()[:300])) h.say("session: %s (created; switch to it with TUIOS's session switcher)" % session) def scaffold(folder, name): """Write the files this project keeps, and only the ones that are missing. Anything already there is left exactly as it is: adopting a folder must never overwrite a person's own notes or scripts. """ written = [] harness = os.path.join(folder, ".harness") os.makedirs(harness, exist_ok=True) check = os.path.join(harness, "check.sh") if not os.path.exists(check): with open(check, "w") as f: f.write("""#!/bin/sh # This project's own check, run by bin/merge before and after it puts a task's # work on the base branch. Exit 0 means "the project still passes". # # It does not decide that a task is finished: an agent sets a task's status, and # the review decides whether the change is good. This is the regression check, # the same thing a CI pipeline would run. # # Leave it as it is while the project has no tests. bin/merge will say there is # no check and let the review decide. Replace the body when tests exist: # # exec go test ./... # exec make test echo "this project has no check yet" exit 0 """) os.chmod(check, 0o755) written.append(check) notes = os.path.join(harness, "NOTES.md") if not os.path.exists(notes): with open(notes, "w") as f: f.write("# %s\n\nWhat this project is, what finished looks like, and anything a person\n" "picking it up next week needs to know. Notes only: tasks live in Paperclip.\n" % name) written.append(notes) return written def main(): opts = parse(sys.argv[1:]) folder = os.path.abspath(os.path.expanduser(opts["folder"])) if not os.path.isdir(folder): raise h.Refused(2, "no folder at %s" % folder) host = opts["host"] or pj.this_machine() # An adopted project already knows its session. Only a new one needs a # default, and a default that names a session nobody made is worse than an # error, so the session is checked before anything is written. existing = {} if pj.exists(folder): existing = pj.load(folder) session = ( opts["session"] or existing.get("session") or os.path.basename(folder) ) # The session is created if it does not exist and the client is moved to it, # so one command takes a person from a folder to a working layout. TUIOS has # the verb for exactly this; asking the person to run `tuios new` first was # the step everyone forgot. if not opts["no_switch"]: ensure_session(session, folder) if opts["host"] and opts["host"] not in (pj.this_machine(), pj.this_machine().split(".")[0]): # Only a note, because the panes are built by the machine that owns them. h.say("host: %s (build the pages there)" % host) ref = opts["project"] if not ref and pj.exists(folder): ref = pj.load(folder)["paperclip"]["project"] if not ref: raise h.Refused( 2, "which Paperclip project is %s? pass --project " % folder, ) proj = pj.resolve_project(ref) h.say("project: %s (%s)" % (proj["name"], proj["id"])) h.say("folder: %s" % folder) h.say("session: %s" % session) h.say("machine: %s" % host) tasks = pj.active_tasks(proj["id"]) watching = next((t["identifier"] for t in tasks if t["status"] not in ("done", "cancelled")), tasks[0]["identifier"] if tasks else "") cfg = { "session": session, "paperclip": {"company": h.COMPANY, "project": proj["id"]}, "machine": {"host": host, "folder": folder}, "watching": watching, } if opts["dry"]: h.say("would write %s" % pj.path(folder)) h.say("would build pages: shell, tree, reply, log, new") return 0 pj.save(folder, cfg) h.say("wrote %s" % pj.path(folder)) pj.register(cfg, proj["name"]) h.say("recorded in %s" % pj.REGISTRY) written = scaffold(folder, proj["name"]) for path in written: h.say("wrote %s" % path) # The pages are built by this machine, from this folder, in this session. code = os.spawnv( os.P_WAIT, sys.executable, [sys.executable, os.path.join(HERE, "board"), "--folder", folder], ) return code if __name__ == "__main__": h.main_guard(main)