@motionode/cli 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -20,5 +20,52 @@ motionode finish T11-518 # Motionode checks your pushed commits and moves the
20
20
  - It runs only fixed git operations Motionode can ask for (check out a ticket
21
21
  branch, show a diff, commit and push, move changes onto a ticket branch) —
22
22
  never arbitrary commands.
23
- - `--api URL` (or `MOTIONODE_API_URL`) points it at another Motionode API.
23
+ - `motionode use <url>` points every terminal at another Motionode API until `motionode use production`; `--api URL` or `MOTIONODE_API_URL` overrides it for one run. `motionode where` shows the current one.
24
24
  - Revoke a computer in Motionode → Settings → Development workflow.
25
+
26
+ ## Repositories Motionode hosts
27
+
28
+ A repository Motionode hosts lives privately in Motionode's GitHub, so your own
29
+ GitHub account does not reach it. Clone it through Motionode instead (0.2.0+):
30
+
31
+ ```sh
32
+ motionode connect # once per computer
33
+ motionode clone acme/orders-api # or the service's name: motionode clone "Orders API"
34
+ ```
35
+
36
+ - The clone talks to Motionode's git proxy, and this CLI is that folder's git
37
+ credential helper (`motionode git-credential`), set in the clone's own git
38
+ config only. Plain git, Cursor, VS Code and Claude Code then just work there.
39
+ - Each sign-in is short-lived and opens one repository. It needs a development
40
+ step on the project, and you can revoke it in Settings → Development workflow →
41
+ Git access, or by disconnecting the computer.
42
+ - Pushes go only to the branch of a ticket you are working locally
43
+ (`motionode work <ticket>`); main, staging, tags and deletes are refused.
44
+
45
+ ## Coding agents (Claude Code, Cursor, any MCP client)
46
+
47
+ Let your own agent work Motionode tickets as a first-class worker. It reads the
48
+ ticket and its acceptance criteria, reports progress and asks questions. Requirement
49
+ changes, overlaps and failed checks reach it between steps.
50
+
51
+ ```sh
52
+ motionode mcp install --claude-code # adds the Motionode MCP server (.mcp.json) and signal hooks (.claude/settings.json)
53
+ motionode signals watch # keep running: signals land in .motionode/inbox.json the moment they happen
54
+ ```
55
+
56
+ - **MCP tools:** `my_work`, `get_ticket`, `get_signals`, `ack_signal` (with `incorporated` once your work reflects it),
57
+ `start_step`, `report_progress`, `ask_question`, `report_discovery`, `request_review`.
58
+ Every call acts as you, with the same permissions as the web app.
59
+ - **Coordination records** (several agents, one person's token):
60
+ - `claim_step` / `release_step`: an exclusive, leased claim on the ticket's current step; other workers see who holds it.
61
+ Name each agent (`motionode mcp install --claude-code --agent opus5`, or `MOTIONODE_AGENT=opus5`) -- claiming needs it.
62
+ - `report_observation` (a symptom + evidence), `report_finding` (a cause; `verified`, `root_cause`),
63
+ `link_observation` and `relate_work` (coordinator/manager: one root cause, duplicates, what else it affects).
64
+ - `ask_decision` / `choose_decision` (an owner, options, a due time), `report_blocker` (Waiting, on a ticket or a decision).
65
+ - `submit_evidence`: `fails_without`, `suite_baseline`, `preview_check`, `tester_recheck` -- bound to a commit; the
66
+ answer carries the ticket's readiness (built → verified → integrated → released).
67
+ - **Hooks** (no model, no network; they read the local inbox):
68
+ - After each tool call and on each prompt, pending signals are added to the agent's context.
69
+ - When the agent tries to finish while a signal that needs action is unacknowledged, it is asked once to handle it first.
70
+ - `motionode signals pending` shows what is waiting.
71
+ - Instead of a connected computer, you can set a personal access token (create one in Settings) in `MOTIONODE_TOKEN`.
package/bin/motionode.js CHANGED
@@ -9,17 +9,41 @@ const { version } = require('../lib/api');
9
9
  const HELP = `motionode ${version} — work Motionode tickets with your own local tools
10
10
 
11
11
  motionode connect [--api URL] [--name NAME] connect this computer (once) and link the current repo
12
- motionode link [--service UUID] link the current repository folder to a Motionode repository
12
+ motionode link [--service UUID] [--worktrees] link the current repository folder to a Motionode repository
13
+ (--worktrees: each ticket gets its own folder next to it)
13
14
  motionode unlink unlink the current folder
15
+ motionode clone <service | owner/repo> [dir] clone a Motionode-hosted repository through Motionode (no GitHub
16
+ access needed: this computer's connection signs git in)
14
17
  motionode dev keep Motionode in sync (branches, local changes, commit requests)
15
18
  motionode work <ticket> start a ticket locally and check out its branches (e.g. T11-518)
16
19
  motionode finish <ticket> [--force] [--note] you're done: Motionode checks your pushed commits and moves it on
17
20
  motionode status what each linked folder has checked out and changed
18
21
  motionode where which Motionode this terminal talks to
22
+ motionode use <url|production> switch which Motionode the CLI talks to (remembered)
19
23
  motionode logout disconnect this computer
20
24
 
21
- Git stays yours: fetch, commit and push use your own git credentials and identity.
22
- Talks to production (https://api.motionode.com) unless MOTIONODE_API_URL or --api says otherwise;
25
+ For coding agents (Claude Code, Cursor, any MCP client):
26
+ motionode mcp run the Motionode MCP server (stdio) for your agent
27
+ motionode mcp install --claude-code add the MCP server and signal hooks to this repo's Claude Code settings
28
+ [--agent <name>] name this agent (MOTIONODE_AGENT) so its claims say who holds a step
29
+ motionode signals watch keep one connection open; Motionode signals land in .motionode/inbox.json
30
+ motionode signals pending what is waiting for this repository
31
+
32
+ Project work and notes (as you; --project <uuid or name>, or MOTIONODE_PROJECT):
33
+ motionode deliverables the project's deliverables
34
+ motionode work-item create --title T file a work item (in setup, nothing starts); --description D or
35
+ [--deliverable R2-1 | --new-deliverable N] --description-file F, --criteria "a | b", --confirmed-new when
36
+ open work with the same title already exists
37
+ motionode work-item update <code> change its --title and/or --description (or --description-file),
38
+ as you (needs edit rights on the project)
39
+ motionode notes list | get <uuid> [--html] the project notes you may read
40
+ motionode notes create --file F.md a note from markdown (--deliverable R or --ticket T: a team note;
41
+ [--share a@x,b@y] [--category C] else a board note shared with the people named)
42
+ motionode notes update <uuid> --file F.md replace a note you wrote or that is shared with you
43
+
44
+ Git stays yours: fetch, commit and push use your own git credentials and identity. A Motionode-hosted repository
45
+ cloned with "motionode clone" signs in through this computer's Motionode connection instead (short-lived, one repo).
46
+ Talks to production (https://api.motionode.com) unless "motionode use", MOTIONODE_API_URL or --api says otherwise;
23
47
  each Motionode keeps its own connection.`;
24
48
 
25
49
  function flags(argv) {
@@ -48,9 +72,14 @@ async function main() {
48
72
  case 'login':
49
73
  return Commands.login(config, { apiUrl: args.api || null, name: args.name || null });
50
74
  case 'link':
51
- return Commands.link(config, { serviceUuid: args.service || null });
75
+ return Commands.link(config, { serviceUuid: args.service || null, worktrees: !!args.worktrees });
52
76
  case 'unlink':
53
77
  return Commands.unlink(config);
78
+ case 'clone':
79
+ return require('../lib/git_credential').clone(config, args._[0], args._[1] || null);
80
+ case 'git-credential':
81
+ // git runs this as a clone's credential helper (see lib/git_credential).
82
+ return require('../lib/git_credential').helper(config, args._[0]);
54
83
  case 'dev':
55
84
  return Bridge.run(config);
56
85
  case 'work':
@@ -59,11 +88,34 @@ async function main() {
59
88
  return Commands.finish(config, args._[0], { force: !!args.force, note: typeof args.note === 'string' ? args.note : null });
60
89
  case 'status':
61
90
  return Commands.status(config);
91
+ case 'use': {
92
+ const api = Config.useApi(args._[0]);
93
+ const next = Config.load();
94
+ console.log(`✓ Using ${api}${next.token ? '' : ' — run "motionode connect" in a repository folder to connect this computer to it'}`);
95
+ return null;
96
+ }
62
97
  case 'where':
63
98
  console.log(`${config.apiUrl}${config.token ? ` — connected as ${config.user?.name || config.user?.email || 'you'} (${config.machine?.name || 'this computer'})` : ' — not connected'}`);
64
99
  return null;
65
100
  case 'logout':
66
101
  return Commands.logout(config);
102
+ case 'mcp':
103
+ if (args._[0] === 'install') return require('../lib/agent_setup').install(config, { claudeCode: !!args['claude-code'] || !args.cursor, cursor: !!args.cursor, agent: typeof args.agent === 'string' ? args.agent : null });
104
+ return require('../lib/mcp').serve(config);
105
+ case 'signals': {
106
+ const sub = args._[0] || 'pending';
107
+ if (sub === 'watch') return require('../lib/signals_watch').watch(config, { quiet: !!args.quiet });
108
+ if (sub === 'hook') return require('../lib/signals_hook').run(args._[1] || 'PostToolUse');
109
+ return require('../lib/signals_hook').printPending();
110
+ }
111
+ case 'deliverables':
112
+ return require('../lib/project_cmds').deliverables(config, args);
113
+ case 'work-item':
114
+ if (args._[0] === 'update') return require('../lib/project_cmds').updateWorkItem(config, args);
115
+ if (args._[0] !== 'create') throw new Error('Usage: motionode work-item create --project P --title T [...] | work-item update <code> --project P [--title T] [--description D | --description-file F]');
116
+ return require('../lib/project_cmds').createWorkItem(config, args);
117
+ case 'notes':
118
+ return require('../lib/project_cmds').notes(config, args);
67
119
  case '--version':
68
120
  case '-v':
69
121
  console.log(version);
@@ -0,0 +1,79 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Client for Motionode's /api/v1/agent — what an external coding agent (Claude
5
+ * Code, Cursor) uses to read its tickets and exchange signals. Same machine token
6
+ * as the rest of the CLI (or MOTIONODE_TOKEN, a personal access token).
7
+ */
8
+
9
+ const pkg = require('../package.json');
10
+
11
+ class AgentApiError extends Error {
12
+ constructor(message, status) {
13
+ super(message);
14
+ this.status = status;
15
+ }
16
+ }
17
+
18
+ /**
19
+ * This agent's name (MOTIONODE_AGENT, e.g. "opus5"), sent as X-Motionode-Client on every call so Motionode can tell
20
+ * several agents acting as the same person apart: each is its own session and holds its own claims. Claiming a step
21
+ * needs it. `motionode mcp install --agent <name>` writes it into .mcp.json.
22
+ */
23
+ function agentName() {
24
+ const name = String(process.env.MOTIONODE_AGENT || '').trim().slice(0, 80);
25
+ return name || null;
26
+ }
27
+
28
+ function agentHeaders() {
29
+ const name = agentName();
30
+ return name ? { 'X-Motionode-Client': name } : {};
31
+ }
32
+
33
+ function agentClient(config) {
34
+ const base = String(config.apiUrl || '').replace(/\/+$/, '');
35
+ const token = process.env.MOTIONODE_TOKEN || config.token || null;
36
+ async function request(method, pathname, body = null, { timeoutMs = 60000 } = {}) {
37
+ if (!token) throw new AgentApiError('Not connected. Run "motionode connect" (or set MOTIONODE_TOKEN).', 401);
38
+ const controller = new AbortController();
39
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
40
+ let resp;
41
+ try {
42
+ resp = await fetch(`${base}/api/v1/agent${pathname}`, {
43
+ method,
44
+ headers: {
45
+ 'Content-Type': 'application/json',
46
+ 'User-Agent': `motionode-cli/${pkg.version}`,
47
+ Authorization: `Bearer ${token}`,
48
+ ...agentHeaders(),
49
+ },
50
+ body: body ? JSON.stringify(body) : undefined,
51
+ signal: controller.signal,
52
+ });
53
+ } catch (err) {
54
+ if (err.name === 'AbortError') throw new AgentApiError('Motionode did not answer in time.', 0);
55
+ throw new AgentApiError(`Could not reach Motionode at ${base}: ${err.message}`, 0);
56
+ } finally {
57
+ clearTimeout(timer);
58
+ }
59
+ let data = null;
60
+ try { data = await resp.json(); } catch (_e) { data = null; }
61
+ if (!resp.ok || data?.ok === false) {
62
+ // A real {ok:false} failure can arrive with HTTP 200 (the body carries the
63
+ // outcome) -- "Motionode answered 200." reads like success, not a refusal.
64
+ const message = data?.error
65
+ || (data?.ok === false ? `Motionode refused: ${resp.status}` : `Motionode answered ${resp.status}.`);
66
+ throw new AgentApiError(message, resp.status);
67
+ }
68
+ return data || {};
69
+ }
70
+ return {
71
+ base,
72
+ token,
73
+ get: (p, opts) => request('GET', p, null, opts),
74
+ post: (p, body, opts) => request('POST', p, body || {}, opts),
75
+ patch: (p, body, opts) => request('PATCH', p, body || {}, opts),
76
+ };
77
+ }
78
+
79
+ module.exports = { agentClient, agentName, agentHeaders, AgentApiError };
@@ -0,0 +1,86 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * `motionode mcp install [--claude-code] [--cursor]`: wire this repository for a coding agent.
5
+ * .mcp.json → the Motionode MCP server (stdio: `motionode mcp`)
6
+ * .claude/settings.json → Claude Code hooks that surface Motionode signals at safe points
7
+ * .cursor/mcp.json → the Motionode MCP server for Cursor (no hooks -- Cursor has none)
8
+ * Merges into existing files (never removes anything): a file that doesn't exist yet is created;
9
+ * a file that exists but fails to parse as JSON stops the install instead of being silently
10
+ * overwritten with an empty object.
11
+ */
12
+
13
+ const fs = require('fs');
14
+ const path = require('path');
15
+ const Inbox = require('./inbox');
16
+
17
+ /** Existing, valid JSON -> parsed. Missing file -> {}. Existing but invalid JSON -> throws (never silently drops it). */
18
+ function readJson(file) {
19
+ let raw;
20
+ try {
21
+ raw = fs.readFileSync(file, 'utf8');
22
+ } catch (err) {
23
+ if (err.code === 'ENOENT') return {};
24
+ throw err;
25
+ }
26
+ try {
27
+ return JSON.parse(raw);
28
+ } catch (_e) {
29
+ throw new Error(`${file} isn't valid JSON — fix it or remove it, then run again`);
30
+ }
31
+ }
32
+
33
+ function writeJson(file, value) {
34
+ fs.mkdirSync(path.dirname(file), { recursive: true });
35
+ fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
36
+ }
37
+
38
+ function addHook(settings, event, matcher, command) {
39
+ settings.hooks = settings.hooks || {};
40
+ const list = Array.isArray(settings.hooks[event]) ? settings.hooks[event] : [];
41
+ const exists = list.some((entry) => (entry.hooks || []).some((h) => h.command === command));
42
+ if (!exists) list.push({ matcher, hooks: [{ type: 'command', command }] });
43
+ settings.hooks[event] = list;
44
+ }
45
+
46
+ function install(config, { claudeCode = true, cursor = false, agent = null } = {}) {
47
+ // --agent <name>: this agent's name for Motionode (X-Motionode-Client), so claims say who holds a step.
48
+ const env = agent ? { env: { MOTIONODE_AGENT: String(agent).trim().slice(0, 80) } } : {};
49
+ const root = Inbox.repoRoot(process.cwd());
50
+ const written = [];
51
+
52
+ const mcpFile = path.join(root, '.mcp.json');
53
+ const mcp = readJson(mcpFile);
54
+ mcp.mcpServers = mcp.mcpServers || {};
55
+ mcp.mcpServers.motionode = { type: 'stdio', command: 'motionode', args: ['mcp'], ...env };
56
+ writeJson(mcpFile, mcp);
57
+ written.push(path.relative(process.cwd(), mcpFile) || '.mcp.json');
58
+
59
+ if (claudeCode) {
60
+ const settingsFile = path.join(root, '.claude', 'settings.json');
61
+ const settings = readJson(settingsFile);
62
+ addHook(settings, 'PostToolUse', '*', 'motionode signals hook PostToolUse');
63
+ addHook(settings, 'UserPromptSubmit', '', 'motionode signals hook UserPromptSubmit');
64
+ addHook(settings, 'Stop', '', 'motionode signals hook Stop');
65
+ writeJson(settingsFile, settings);
66
+ written.push(path.relative(process.cwd(), settingsFile));
67
+ }
68
+
69
+ if (cursor) {
70
+ // Cursor has no hook system -- just the MCP server, in its own config file.
71
+ const cursorFile = path.join(root, '.cursor', 'mcp.json');
72
+ const cursorConfig = readJson(cursorFile);
73
+ cursorConfig.mcpServers = cursorConfig.mcpServers || {};
74
+ cursorConfig.mcpServers.motionode = { command: 'motionode', args: ['mcp'], ...env };
75
+ writeJson(cursorFile, cursorConfig);
76
+ written.push(path.relative(process.cwd(), cursorFile));
77
+ }
78
+
79
+ Inbox.ensureExcluded(root);
80
+ for (const file of written) console.log(`✓ Wrote ${file}`);
81
+ if (claudeCode) console.log('✓ Claude Code hooks added to .claude/settings.json (signals reach your agent between steps)');
82
+ if (cursor) console.log('✓ Cursor MCP server added to .cursor/mcp.json (no hooks -- Cursor has none; signals still reach it through "motionode signals watch")');
83
+ console.log(`Next: keep "motionode signals watch" running in a terminal for this repository${config?.token ? '' : ', after "motionode connect"'}.`);
84
+ }
85
+
86
+ module.exports = { install, addHook, readJson, writeJson };
package/lib/bridge.js CHANGED
@@ -27,6 +27,7 @@ async function collect(config) {
27
27
  out.push({
28
28
  serviceUuid: ws.serviceUuid,
29
29
  path: ws.path,
30
+ worktrees: !!ws.worktrees,
30
31
  remoteUrl: await Git.remoteUrl(ws.path),
31
32
  branch: st.branch,
32
33
  headSha: st.headSha,
@@ -35,7 +36,7 @@ async function collect(config) {
35
36
  dirtyFiles: st.dirtyFiles,
36
37
  });
37
38
  } catch (err) {
38
- out.push({ serviceUuid: ws.serviceUuid, path: ws.path, gitError: err.message });
39
+ out.push({ serviceUuid: ws.serviceUuid, path: ws.path, worktrees: !!ws.worktrees, gitError: err.message });
39
40
  }
40
41
  }
41
42
  return out;
package/lib/commands.js CHANGED
@@ -1,6 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const os = require('os');
4
+ const path = require('path');
4
5
  const readline = require('readline');
5
6
  const { execFile } = require('child_process');
6
7
  const Config = require('./config');
@@ -13,6 +14,8 @@ function ask(question) {
13
14
  }
14
15
 
15
16
  function openBrowser(url) {
17
+ // Headless/SSH sessions: the printed URL and code are enough.
18
+ if (process.env.MOTIONODE_NO_BROWSER) return;
16
19
  const cmd = process.platform === 'win32' ? 'cmd' : (process.platform === 'darwin' ? 'open' : 'xdg-open');
17
20
  const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
18
21
  execFile(cmd, args, { windowsHide: true }, () => {});
@@ -50,7 +53,7 @@ async function login(config, { name = null } = {}) {
50
53
  }
51
54
 
52
55
  /** Link the current folder to a Motionode repository (matched by its git remote when possible). */
53
- async function link(config, { serviceUuid = null, dir = process.cwd() } = {}) {
56
+ async function link(config, { serviceUuid = null, dir = process.cwd(), worktrees = false } = {}) {
54
57
  const root = await Git.isRepo(dir);
55
58
  if (!root) throw new Error('This folder is not a git repository. Run `motionode link` inside your clone.');
56
59
  const api = client(config);
@@ -77,6 +80,7 @@ async function link(config, { serviceUuid = null, dir = process.cwd() } = {}) {
77
80
  config.workspaces = (config.workspaces || []).filter((w) => !Config.samePath(w.path, root) && w.serviceUuid !== pick.uuid);
78
81
  config.workspaces.push({
79
82
  path: root,
83
+ worktrees: !!worktrees,
80
84
  serviceUuid: pick.uuid,
81
85
  serviceName: pick.name,
82
86
  projectUuid: pick.projectUuid,
@@ -84,11 +88,21 @@ async function link(config, { serviceUuid = null, dir = process.cwd() } = {}) {
84
88
  });
85
89
  Config.save(config);
86
90
  console.log(`✓ ${root} → ${pick.projectName} / ${pick.name}`);
91
+ if (worktrees) console.log(' Each ticket you start gets its own folder next to this one (git worktree), so tickets never share a working tree.');
87
92
  return pick;
88
93
  }
89
94
 
90
95
  async function connect(config, opts = {}) {
91
- if (!config.token || opts.relogin) await login(config, opts);
96
+ let relogin = !config.token || opts.relogin;
97
+ if (!relogin) {
98
+ // A computer revoked in Motionode keeps its old token here; connecting again must replace it.
99
+ try { await client(config).get('/me'); } catch (err) {
100
+ if (err.status !== 401) throw err;
101
+ console.log('This computer was disconnected from Motionode — connecting it again.');
102
+ relogin = true;
103
+ }
104
+ }
105
+ if (relogin) await login(config, opts);
92
106
  if (await Git.isRepo(process.cwd())) await link(config, { serviceUuid: opts.service || null });
93
107
  console.log('');
94
108
  console.log('Next: keep `motionode dev` running so Motionode sees your branches and local changes,');
@@ -127,6 +141,22 @@ async function status(config) {
127
141
  }
128
142
 
129
143
  /** Start (or resume) a ticket locally and check its branches out here. */
144
+ /** Where a ticket's own folder goes: next to the linked folder, one per ticket. */
145
+ function worktreePath(ws, ticketRef) {
146
+ return path.join(path.dirname(ws.path), `${path.basename(ws.path)}.worktrees`, String(ticketRef).replace(/[^A-Za-z0-9._-]/g, '_'));
147
+ }
148
+
149
+ /** What `motionode finish` tells the person was recorded (the same facts the web confirmation shows). */
150
+ function finishLines(res) {
151
+ const lines = [`✓ ${res.summary || 'Build finished.'}`];
152
+ const rec = res.recorded;
153
+ if (rec) {
154
+ lines.push(` Recorded: ${rec.commitCount} commit${rec.commitCount === 1 ? '' : 's'} on the ticket branch${rec.commits?.length ? ` (${rec.commits.map((c) => c.sha).join(', ')})` : ''}${rec.note ? `, note "${rec.note}"` : ''}.`);
155
+ if (rec.next?.stepName || rec.next?.message) lines.push(` Next: ${rec.next.message || rec.next.stepName}${rec.next.ownerName ? ` (${rec.next.ownerName})` : ''}`);
156
+ }
157
+ return lines;
158
+ }
159
+
130
160
  async function work(config, ref) {
131
161
  if (!ref) throw new Error('Which ticket? e.g. motionode work T11-518');
132
162
  const api = client(config);
@@ -140,8 +170,15 @@ async function work(config, ref) {
140
170
  continue;
141
171
  }
142
172
  try {
143
- await Git.checkout(ws.path, b.branch);
144
- console.log(` ${b.serviceName}: checked out ${b.branch} in ${ws.path}`);
173
+ if (ws.worktrees) {
174
+ const dir = worktreePath(ws, lw.ticketDisplayId || ref);
175
+ const wt = await Git.worktreeFor(ws.path, b.branch, dir);
176
+ console.log(` ${b.serviceName}: ${wt.created ? 'created' : 'reused'} a worktree on ${b.branch} in ${wt.path}`);
177
+ console.log(` Open ${wt.path} in your tool; your linked folder ${ws.path} is untouched.`);
178
+ } else {
179
+ await Git.checkout(ws.path, b.branch);
180
+ console.log(` ${b.serviceName}: checked out ${b.branch} in ${ws.path}`);
181
+ }
145
182
  } catch (err) {
146
183
  console.log(` ${b.serviceName}: could not check out ${b.branch} — ${err.message}`);
147
184
  if (/denied|403|401|authentication|permission/i.test(err.message)) {
@@ -156,8 +193,9 @@ async function finish(config, ref, { force = false, note = null } = {}) {
156
193
  if (!ref) throw new Error('Which ticket? e.g. motionode finish T11-518');
157
194
  const api = client(config);
158
195
  try {
196
+ console.log(`Checking the ticket branch and telling Motionode ${ref} is built…`);
159
197
  const res = await api.post(`/tickets/${encodeURIComponent(ref)}/finish`, { force, note });
160
- console.log(`✓ ${res.summary || 'Build finished.'}`);
198
+ finishLines(res).forEach((l) => console.log(l));
161
199
  } catch (err) {
162
200
  console.log(`✗ ${err.message}`);
163
201
  if (/not on the ticket branch|not pushed|uncommitted/i.test(err.message)) {
@@ -172,4 +210,4 @@ async function logout(config) {
172
210
  console.log('✓ Disconnected this computer. (Remove it in Motionode Settings → Development workflow to revoke it there too.)');
173
211
  }
174
212
 
175
- module.exports = { login, link, connect, unlink, status, work, finish, logout };
213
+ module.exports = { login, link, connect, unlink, status, work, finish, logout, worktreePath, finishLines };
package/lib/config.js CHANGED
@@ -21,9 +21,33 @@ function normalizeApi(url) {
21
21
  return String(url || '').trim().replace(/\/+$/, '') || null;
22
22
  }
23
23
 
24
- /** The API this run talks to: --api, then MOTIONODE_API_URL, then production. */
24
+ const CURRENT_FILE = path.join(DIR, 'current.json');
25
+
26
+ /** The Motionode chosen with `motionode use` (stays until changed). */
27
+ function savedApi() {
28
+ try {
29
+ return normalizeApi(JSON.parse(fs.readFileSync(CURRENT_FILE, 'utf8')).apiUrl);
30
+ } catch (_e) {
31
+ return null;
32
+ }
33
+ }
34
+
35
+ /** `motionode use <url|production>` — which Motionode every terminal talks to. */
36
+ function useApi(url) {
37
+ const next = String(url || '').trim().toLowerCase() === 'production' ? PRODUCTION_API : normalizeApi(url);
38
+ if (!next || !/^https?:\/\//.test(next)) throw new Error('Give a Motionode API address (https://…) or "production".');
39
+ fs.mkdirSync(DIR, { recursive: true });
40
+ if (next === PRODUCTION_API) {
41
+ try { fs.unlinkSync(CURRENT_FILE); } catch (_e) { /* already production */ }
42
+ } else {
43
+ fs.writeFileSync(CURRENT_FILE, JSON.stringify({ apiUrl: next }, null, 2));
44
+ }
45
+ return next;
46
+ }
47
+
48
+ /** The API this run talks to: --api, then MOTIONODE_API_URL, then `motionode use`, then production. */
25
49
  function resolveApi(override = null) {
26
- return normalizeApi(override) || normalizeApi(process.env.MOTIONODE_API_URL) || PRODUCTION_API;
50
+ return normalizeApi(override) || normalizeApi(process.env.MOTIONODE_API_URL) || savedApi() || PRODUCTION_API;
27
51
  }
28
52
 
29
53
  function fileFor(apiUrl) {
@@ -63,4 +87,4 @@ function workspaceForPath(config, dir) {
63
87
  return (config.workspaces || []).find((w) => samePath(w.path, dir)) || null;
64
88
  }
65
89
 
66
- module.exports = { DIR, PRODUCTION_API, resolveApi, fileFor, load, save, samePath, workspaceForPath };
90
+ module.exports = { DIR, PRODUCTION_API, resolveApi, useApi, fileFor, load, save, samePath, workspaceForPath };
package/lib/git.js CHANGED
@@ -112,6 +112,27 @@ async function checkout(dir, branch) {
112
112
  return status(dir);
113
113
  }
114
114
 
115
+ /**
116
+ * A separate folder (git worktree) on a ticket's branch, so two tickets of one repository never share a working tree.
117
+ * Reuses the folder when this ticket already has one. Returns { path, created }.
118
+ */
119
+ async function worktreeFor(dir, branch, path) {
120
+ const fs = require('fs');
121
+ if (fs.existsSync(path)) {
122
+ const r = await run(path, ['rev-parse', '--abbrev-ref', 'HEAD'], { allowFail: true });
123
+ if (r.ok && String(r.stdout || '').trim() === branch) {
124
+ await run(path, ['fetch', 'origin', branch], { allowFail: true });
125
+ await run(path, ['merge', '--ff-only', `origin/${branch}`], { allowFail: true });
126
+ return { path, created: false };
127
+ }
128
+ throw new Error(`${path} exists and is not a checkout of ${branch}`);
129
+ }
130
+ await run(dir, ['fetch', 'origin', branch]);
131
+ if (await hasLocalBranch(dir, branch)) await run(dir, ['worktree', 'add', path, branch]);
132
+ else await run(dir, ['worktree', 'add', '--track', '-b', branch, path, `origin/${branch}`]);
133
+ return { path, created: true };
134
+ }
135
+
115
136
  /** The uncommitted diff, including new files. */
116
137
  async function diff(dir, onlyPath = null) {
117
138
  const args = ['diff', 'HEAD'];
@@ -178,4 +199,4 @@ async function moveChanges(dir, branch) {
178
199
  return { moved: true, branch };
179
200
  }
180
201
 
181
- module.exports = { run, isRepo, remoteUrl, normalizeRepoUrl, status, checkout, diff, commit, moveChanges };
202
+ module.exports = { run, isRepo, remoteUrl, normalizeRepoUrl, status, checkout, worktreeFor, diff, commit, moveChanges };
@@ -0,0 +1,134 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Git access to Motionode-hosted repositories, through Motionode.
5
+ *
6
+ * A Motionode-hosted repository lives privately in Motionode's GitHub organization, so your own GitHub account does not
7
+ * reach it. `motionode clone` clones it from this Motionode's git proxy (<api>/git/<owner>/<repo>.git) and sets this
8
+ * CLI as the clone's git credential helper: each time git needs to authenticate, the helper asks Motionode for a
9
+ * short-lived credential for that one repository, as the connected computer. Plain git (and Cursor, VS Code, Claude
10
+ * Code) then just works in that folder. Nothing is written to your global git config.
11
+ */
12
+
13
+ const path = require('path');
14
+ const { spawn } = require('child_process');
15
+ const { client } = require('./api');
16
+
17
+ /** git credential protocol: key=value lines. */
18
+ function parseCredentialInput(raw) {
19
+ const out = {};
20
+ for (const line of String(raw || '').split(/\r?\n/)) {
21
+ const i = line.indexOf('=');
22
+ if (i > 0) out[line.slice(0, i)] = line.slice(i + 1);
23
+ }
24
+ return out;
25
+ }
26
+
27
+ function readStdin() {
28
+ return new Promise((resolve) => {
29
+ let data = '';
30
+ process.stdin.setEncoding('utf8');
31
+ process.stdin.on('data', (chunk) => { data += chunk; });
32
+ process.stdin.on('end', () => resolve(data));
33
+ process.stdin.on('error', () => resolve(data));
34
+ });
35
+ }
36
+
37
+ /** Whether a git credential request is for this Motionode's git proxy. */
38
+ function isForThisMotionode(config, input) {
39
+ try {
40
+ const api = new URL(config.apiUrl);
41
+ return String(input.host || '').toLowerCase() === api.host.toLowerCase()
42
+ && String(input.protocol || '').toLowerCase() === api.protocol.replace(/:$/, '');
43
+ } catch (_e) {
44
+ return false;
45
+ }
46
+ }
47
+
48
+ /**
49
+ * `motionode git-credential <get|store|erase>` — git runs this as the clone's credential helper. Only `get` answers;
50
+ * a request for any other host gets no answer, so git falls back to whatever else is configured.
51
+ */
52
+ async function helper(config, operation, { input = null, out = process.stdout } = {}) {
53
+ if (operation !== 'get') return null;
54
+ const fields = parseCredentialInput(input == null ? await readStdin() : input);
55
+ if (!isForThisMotionode(config, fields) || !config.token) return null;
56
+ const pathname = String(fields.path || '').replace(/^\/+/, '');
57
+ if (!pathname.startsWith('git/')) return null;
58
+ try {
59
+ const { credential } = await client(config).post('/git-credential', { path: pathname });
60
+ out.write(`username=${credential.username}\npassword=${credential.password}\n`);
61
+ } catch (err) {
62
+ // git shows helper stderr; say why, then give no credential (git then reports the refusal).
63
+ process.stderr.write(`motionode: ${err.message}\n`);
64
+ }
65
+ return null;
66
+ }
67
+
68
+ /** The proxy address of a Motionode-hosted repository on this Motionode. */
69
+ function proxyUrl(config, { owner, repo }) {
70
+ return `${String(config.apiUrl).replace(/\/+$/, '')}/git/${owner}/${String(repo).replace(/\.git$/, '')}.git`;
71
+ }
72
+
73
+ /** owner/repo of a github.com URL or an "owner/repo" string. */
74
+ function ownerRepoOf(value) {
75
+ const m = String(value || '').trim().match(/(?:github\.com[/:])?([^/\s:]+)\/([^/\s]+?)(?:\.git)?\/?$/);
76
+ return m ? { owner: m[1], repo: m[2] } : null;
77
+ }
78
+
79
+ /** The git config that makes this CLI the credential helper for one folder (never global). */
80
+ function helperConfig(config) {
81
+ const bin = path.resolve(__dirname, '..', 'bin', 'motionode.js');
82
+ const quote = (s) => `'${String(s).replace(/'/g, "'\\''")}'`;
83
+ return [
84
+ ['credential.helper', ''],
85
+ ['credential.helper', `!${quote(process.execPath)} ${quote(bin)} git-credential --api ${quote(config.apiUrl)}`],
86
+ ['credential.useHttpPath', 'true'],
87
+ ];
88
+ }
89
+
90
+ function git(args, { cwd = process.cwd() } = {}) {
91
+ return new Promise((resolve, reject) => {
92
+ const proc = spawn('git', args, { cwd, stdio: 'inherit' });
93
+ proc.on('error', reject);
94
+ proc.on('close', (code) => (code === 0 ? resolve() : reject(new Error(`git ${args[0]} failed (exit ${code}).`))));
95
+ });
96
+ }
97
+
98
+ /**
99
+ * `motionode clone <service name | owner/repo> [dir]` — clone a Motionode-hosted repository through Motionode and
100
+ * keep this CLI as that folder's credential helper.
101
+ */
102
+ async function clone(config, target, dir = null) {
103
+ if (!config.token) throw new Error('Connect this computer first: run `motionode connect`.');
104
+ if (!target) throw new Error('Usage: motionode clone <service name | owner/repo> [folder]');
105
+ let ref = String(target).includes('/') ? ownerRepoOf(target) : null;
106
+ if (!ref) {
107
+ const me = await client(config).get('/me');
108
+ const wanted = String(target).trim().toLowerCase();
109
+ const services = (me.projects || []).flatMap((p) => (p.services || []).map((s) => ({ ...s, project: p.name })));
110
+ const hits = services.filter((s) => s.hosting === 'motionode' && [s.name, s.uuid].map((v) => String(v || '').toLowerCase()).includes(wanted));
111
+ if (hits.length !== 1) {
112
+ throw new Error(hits.length
113
+ ? `More than one service is called "${target}": ${hits.map((s) => `${s.name} (${s.project})`).join(', ')}. Use owner/repo.`
114
+ : `No Motionode-hosted service called "${target}" on your projects.`);
115
+ }
116
+ ref = ownerRepoOf(hits[0].repoUrl);
117
+ if (!ref) throw new Error(`"${target}" has no repository yet.`);
118
+ }
119
+ const folder = dir || ref.repo;
120
+ const settings = helperConfig(config);
121
+ const cloneArgs = [...settings.flatMap(([k, v]) => ['-c', `${k}=${v}`]), 'clone'];
122
+ await git([...cloneArgs, proxyUrl(config, ref), folder]);
123
+ // Keep the helper in the clone's own config, so later fetches and pulls authenticate the same way.
124
+ const cwd = path.resolve(folder);
125
+ await git(['config', '--local', '--unset-all', 'credential.helper'], { cwd }).catch(() => {});
126
+ for (const [k, v] of settings) {
127
+ // eslint-disable-next-line no-await-in-loop
128
+ await git(['config', '--local', '--add', k, v], { cwd });
129
+ }
130
+ console.log(`✓ Cloned ${ref.owner}/${ref.repo} into ${folder} through Motionode. Run \`motionode link\` inside it to tie it to your tickets.`);
131
+ return null;
132
+ }
133
+
134
+ module.exports = { helper, clone, parseCredentialInput, isForThisMotionode, proxyUrl, ownerRepoOf, helperConfig };