@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 +48 -1
- package/bin/motionode.js +56 -4
- package/lib/agent_api.js +79 -0
- package/lib/agent_setup.js +86 -0
- package/lib/bridge.js +2 -1
- package/lib/commands.js +44 -6
- package/lib/config.js +27 -3
- package/lib/git.js +22 -1
- package/lib/git_credential.js +134 -0
- package/lib/inbox.js +75 -0
- package/lib/mcp.js +327 -0
- package/lib/project_cmds.js +116 -0
- package/lib/signals_hook.js +58 -0
- package/lib/signals_watch.js +168 -0
- package/package.json +1 -1
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`
|
|
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]
|
|
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
|
-
|
|
22
|
-
|
|
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);
|
package/lib/agent_api.js
ADDED
|
@@ -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
|
-
|
|
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
|
-
|
|
144
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 };
|