@motionode/cli 0.1.2 → 0.2.1
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 +47 -0
- package/bin/motionode.js +55 -3
- package/lib/agent_api.js +79 -0
- package/lib/agent_setup.js +86 -0
- package/lib/bridge.js +15 -1
- package/lib/commands.js +80 -9
- package/lib/git.js +22 -1
- package/lib/git_credential.js +152 -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
|
@@ -22,3 +22,50 @@ motionode finish T11-518 # Motionode checks your pushed commits and moves the
|
|
|
22
22
|
never arbitrary commands.
|
|
23
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,8 +9,11 @@ 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
|
|
@@ -19,7 +22,27 @@ const HELP = `motionode ${version} — work Motionode tickets with your own loca
|
|
|
19
22
|
motionode use <url|production> switch which Motionode the CLI talks to (remembered)
|
|
20
23
|
motionode logout disconnect this computer
|
|
21
24
|
|
|
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).
|
|
23
46
|
Talks to production (https://api.motionode.com) unless "motionode use", MOTIONODE_API_URL or --api says otherwise;
|
|
24
47
|
each Motionode keeps its own connection.`;
|
|
25
48
|
|
|
@@ -41,6 +64,13 @@ function flags(argv) {
|
|
|
41
64
|
async function main() {
|
|
42
65
|
const [command, ...rest] = process.argv.slice(2);
|
|
43
66
|
const args = flags(rest);
|
|
67
|
+
/**
|
|
68
|
+
* A server named for `connect` is the one this computer keeps using: the work
|
|
69
|
+
* panel prints `motionode connect --api …` once, and every command after it
|
|
70
|
+
* must stay on that Motionode rather than fall back to production
|
|
71
|
+
* (QA-20261023T234000Z).
|
|
72
|
+
*/
|
|
73
|
+
if (command === 'connect' && typeof args.api === 'string') Config.useApi(args.api);
|
|
44
74
|
// --api (or MOTIONODE_API_URL) picks the Motionode — and its own saved connection.
|
|
45
75
|
const config = Config.load(typeof args.api === 'string' ? args.api : null);
|
|
46
76
|
switch (command) {
|
|
@@ -49,9 +79,14 @@ async function main() {
|
|
|
49
79
|
case 'login':
|
|
50
80
|
return Commands.login(config, { apiUrl: args.api || null, name: args.name || null });
|
|
51
81
|
case 'link':
|
|
52
|
-
return Commands.link(config, { serviceUuid: args.service || null });
|
|
82
|
+
return Commands.link(config, { serviceUuid: args.service || null, worktrees: !!args.worktrees });
|
|
53
83
|
case 'unlink':
|
|
54
84
|
return Commands.unlink(config);
|
|
85
|
+
case 'clone':
|
|
86
|
+
return require('../lib/git_credential').clone(config, args._[0], args._[1] || null);
|
|
87
|
+
case 'git-credential':
|
|
88
|
+
// git runs this as a clone's credential helper (see lib/git_credential).
|
|
89
|
+
return require('../lib/git_credential').helper(config, args._[0]);
|
|
55
90
|
case 'dev':
|
|
56
91
|
return Bridge.run(config);
|
|
57
92
|
case 'work':
|
|
@@ -71,6 +106,23 @@ async function main() {
|
|
|
71
106
|
return null;
|
|
72
107
|
case 'logout':
|
|
73
108
|
return Commands.logout(config);
|
|
109
|
+
case 'mcp':
|
|
110
|
+
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 });
|
|
111
|
+
return require('../lib/mcp').serve(config);
|
|
112
|
+
case 'signals': {
|
|
113
|
+
const sub = args._[0] || 'pending';
|
|
114
|
+
if (sub === 'watch') return require('../lib/signals_watch').watch(config, { quiet: !!args.quiet });
|
|
115
|
+
if (sub === 'hook') return require('../lib/signals_hook').run(args._[1] || 'PostToolUse');
|
|
116
|
+
return require('../lib/signals_hook').printPending();
|
|
117
|
+
}
|
|
118
|
+
case 'deliverables':
|
|
119
|
+
return require('../lib/project_cmds').deliverables(config, args);
|
|
120
|
+
case 'work-item':
|
|
121
|
+
if (args._[0] === 'update') return require('../lib/project_cmds').updateWorkItem(config, args);
|
|
122
|
+
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]');
|
|
123
|
+
return require('../lib/project_cmds').createWorkItem(config, args);
|
|
124
|
+
case 'notes':
|
|
125
|
+
return require('../lib/project_cmds').notes(config, args);
|
|
74
126
|
case '--version':
|
|
75
127
|
case '-v':
|
|
76
128
|
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
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
const Git = require('./git');
|
|
12
|
+
const { worktreePath } = require('./commands');
|
|
12
13
|
const { client } = require('./api');
|
|
13
14
|
const { version } = require('./api');
|
|
14
15
|
|
|
@@ -27,6 +28,7 @@ async function collect(config) {
|
|
|
27
28
|
out.push({
|
|
28
29
|
serviceUuid: ws.serviceUuid,
|
|
29
30
|
path: ws.path,
|
|
31
|
+
worktrees: !!ws.worktrees,
|
|
30
32
|
remoteUrl: await Git.remoteUrl(ws.path),
|
|
31
33
|
branch: st.branch,
|
|
32
34
|
headSha: st.headSha,
|
|
@@ -35,7 +37,7 @@ async function collect(config) {
|
|
|
35
37
|
dirtyFiles: st.dirtyFiles,
|
|
36
38
|
});
|
|
37
39
|
} catch (err) {
|
|
38
|
-
out.push({ serviceUuid: ws.serviceUuid, path: ws.path, gitError: err.message });
|
|
40
|
+
out.push({ serviceUuid: ws.serviceUuid, path: ws.path, worktrees: !!ws.worktrees, gitError: err.message });
|
|
39
41
|
}
|
|
40
42
|
}
|
|
41
43
|
return out;
|
|
@@ -50,6 +52,18 @@ async function runJob(config, job) {
|
|
|
50
52
|
const ws = workspaceFor(config, payload.serviceUuid);
|
|
51
53
|
if (!ws) throw new Error(`No folder on this computer is linked to ${payload.serviceName || 'that repository'}. Run \`motionode link\` in it.`);
|
|
52
54
|
if (job.kind === 'checkout') {
|
|
55
|
+
/**
|
|
56
|
+
* In --worktrees mode `motionode work` owns the ticket's branch in its own
|
|
57
|
+
* folder, so checking it out in the linked folder conflicts with that
|
|
58
|
+
* worktree (QA-20261023T235400Z). Create/reuse the same folder instead.
|
|
59
|
+
*/
|
|
60
|
+
if (ws.worktrees && payload.ticketDisplayId) {
|
|
61
|
+
const dir = worktreePath(ws, payload.ticketDisplayId);
|
|
62
|
+
const wt = await Git.worktreeFor(ws.path, payload.branch, dir);
|
|
63
|
+
log(`checked out ${payload.branch} in ${wt.path}`);
|
|
64
|
+
const st = await Git.status(wt.path);
|
|
65
|
+
return { branch: st.branch, headSha: st.headSha, path: wt.path };
|
|
66
|
+
}
|
|
53
67
|
const st = await Git.checkout(ws.path, payload.branch);
|
|
54
68
|
log(`checked out ${payload.branch} in ${ws.path}`);
|
|
55
69
|
return { branch: st.branch, headSha: st.headSha };
|
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);
|
|
@@ -62,8 +65,26 @@ async function link(config, { serviceUuid = null, dir = process.cwd() } = {}) {
|
|
|
62
65
|
if (!services.length) throw new Error('You have no projects with AI building enabled to work on locally.');
|
|
63
66
|
let pick = serviceUuid ? services.find((s) => s.uuid === serviceUuid) : null;
|
|
64
67
|
if (!pick) {
|
|
65
|
-
const
|
|
66
|
-
const
|
|
68
|
+
const remoteUrl = await Git.remoteUrl(root);
|
|
69
|
+
const remote = Git.normalizeRepoUrl(remoteUrl);
|
|
70
|
+
/**
|
|
71
|
+
* A clone made with `motionode clone` has this Motionode's git proxy as its
|
|
72
|
+
* remote, not GitHub's URL; recognize the repository behind it instead of
|
|
73
|
+
* asking the person to choose what they just cloned
|
|
74
|
+
* (QA-20261023T234000Z).
|
|
75
|
+
*/
|
|
76
|
+
const { proxiedRepoOf, ownerRepoOf } = require('./git_credential');
|
|
77
|
+
const proxied = proxiedRepoOf(config, remoteUrl);
|
|
78
|
+
const sameRepo = (repoUrl) => {
|
|
79
|
+
if (!proxied || !repoUrl) return false;
|
|
80
|
+
const ref = ownerRepoOf(repoUrl);
|
|
81
|
+
return !!ref
|
|
82
|
+
&& ref.owner.toLowerCase() === proxied.owner.toLowerCase()
|
|
83
|
+
&& ref.repo.toLowerCase() === proxied.repo.toLowerCase();
|
|
84
|
+
};
|
|
85
|
+
const matches = remote
|
|
86
|
+
? services.filter((s) => Git.normalizeRepoUrl(s.repoUrl) === remote || sameRepo(s.repoUrl))
|
|
87
|
+
: [];
|
|
67
88
|
if (matches.length === 1) pick = matches[0];
|
|
68
89
|
else {
|
|
69
90
|
const list = matches.length ? matches : services;
|
|
@@ -77,6 +98,7 @@ async function link(config, { serviceUuid = null, dir = process.cwd() } = {}) {
|
|
|
77
98
|
config.workspaces = (config.workspaces || []).filter((w) => !Config.samePath(w.path, root) && w.serviceUuid !== pick.uuid);
|
|
78
99
|
config.workspaces.push({
|
|
79
100
|
path: root,
|
|
101
|
+
worktrees: !!worktrees,
|
|
80
102
|
serviceUuid: pick.uuid,
|
|
81
103
|
serviceName: pick.name,
|
|
82
104
|
projectUuid: pick.projectUuid,
|
|
@@ -84,11 +106,21 @@ async function link(config, { serviceUuid = null, dir = process.cwd() } = {}) {
|
|
|
84
106
|
});
|
|
85
107
|
Config.save(config);
|
|
86
108
|
console.log(`✓ ${root} → ${pick.projectName} / ${pick.name}`);
|
|
109
|
+
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
110
|
return pick;
|
|
88
111
|
}
|
|
89
112
|
|
|
90
113
|
async function connect(config, opts = {}) {
|
|
91
|
-
|
|
114
|
+
let relogin = !config.token || opts.relogin;
|
|
115
|
+
if (!relogin) {
|
|
116
|
+
// A computer revoked in Motionode keeps its old token here; connecting again must replace it.
|
|
117
|
+
try { await client(config).get('/me'); } catch (err) {
|
|
118
|
+
if (err.status !== 401) throw err;
|
|
119
|
+
console.log('This computer was disconnected from Motionode — connecting it again.');
|
|
120
|
+
relogin = true;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (relogin) await login(config, opts);
|
|
92
124
|
if (await Git.isRepo(process.cwd())) await link(config, { serviceUuid: opts.service || null });
|
|
93
125
|
console.log('');
|
|
94
126
|
console.log('Next: keep `motionode dev` running so Motionode sees your branches and local changes,');
|
|
@@ -127,6 +159,37 @@ async function status(config) {
|
|
|
127
159
|
}
|
|
128
160
|
|
|
129
161
|
/** Start (or resume) a ticket locally and check its branches out here. */
|
|
162
|
+
/** Where a ticket's own folder goes: next to the linked folder, one per ticket. */
|
|
163
|
+
function worktreePath(ws, ticketRef) {
|
|
164
|
+
return path.join(path.dirname(ws.path), `${path.basename(ws.path)}.worktrees`, String(ticketRef).replace(/[^A-Za-z0-9._-]/g, '_'));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* How to get a service whose repository has no linked folder here. A Motionode-hosted
|
|
169
|
+
* repository is private to Motionode, so its GitHub URL is not a clone the person can use:
|
|
170
|
+
* clone it through Motionode, against the server this command is already talking to.
|
|
171
|
+
*/
|
|
172
|
+
function cloneGuidance(b) {
|
|
173
|
+
if (b?.motionodeHosted) {
|
|
174
|
+
return b.repo
|
|
175
|
+
? `no linked folder here — clone it through Motionode: \`motionode clone ${b.repo}\`, then run \`motionode link\``
|
|
176
|
+
: 'no linked folder here — clone it through Motionode with `motionode clone <owner/repo>`, then run `motionode link`';
|
|
177
|
+
}
|
|
178
|
+
if (b?.cloneUrl) return `no linked folder here — \`git clone ${b.cloneUrl}\`, then run \`motionode link\``;
|
|
179
|
+
return 'no linked folder here — clone the repository, then run `motionode link`';
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** What `motionode finish` tells the person was recorded (the same facts the web confirmation shows). */
|
|
183
|
+
function finishLines(res) {
|
|
184
|
+
const lines = [`✓ ${res.summary || 'Build finished.'}`];
|
|
185
|
+
const rec = res.recorded;
|
|
186
|
+
if (rec) {
|
|
187
|
+
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}"` : ''}.`);
|
|
188
|
+
if (rec.next?.stepName || rec.next?.message) lines.push(` Next: ${rec.next.message || rec.next.stepName}${rec.next.ownerName ? ` (${rec.next.ownerName})` : ''}`);
|
|
189
|
+
}
|
|
190
|
+
return lines;
|
|
191
|
+
}
|
|
192
|
+
|
|
130
193
|
async function work(config, ref) {
|
|
131
194
|
if (!ref) throw new Error('Which ticket? e.g. motionode work T11-518');
|
|
132
195
|
const api = client(config);
|
|
@@ -136,12 +199,19 @@ async function work(config, ref) {
|
|
|
136
199
|
for (const b of lw.branches || []) {
|
|
137
200
|
const ws = (config.workspaces || []).find((w) => w.serviceUuid === b.serviceUuid);
|
|
138
201
|
if (!ws) {
|
|
139
|
-
console.log(` ${b.serviceName}: ${b.branch} (
|
|
202
|
+
console.log(` ${b.serviceName}: ${b.branch} (${cloneGuidance(b)})`);
|
|
140
203
|
continue;
|
|
141
204
|
}
|
|
142
205
|
try {
|
|
143
|
-
|
|
144
|
-
|
|
206
|
+
if (ws.worktrees) {
|
|
207
|
+
const dir = worktreePath(ws, lw.ticketDisplayId || ref);
|
|
208
|
+
const wt = await Git.worktreeFor(ws.path, b.branch, dir);
|
|
209
|
+
console.log(` ${b.serviceName}: ${wt.created ? 'created' : 'reused'} a worktree on ${b.branch} in ${wt.path}`);
|
|
210
|
+
console.log(` Open ${wt.path} in your tool; your linked folder ${ws.path} is untouched.`);
|
|
211
|
+
} else {
|
|
212
|
+
await Git.checkout(ws.path, b.branch);
|
|
213
|
+
console.log(` ${b.serviceName}: checked out ${b.branch} in ${ws.path}`);
|
|
214
|
+
}
|
|
145
215
|
} catch (err) {
|
|
146
216
|
console.log(` ${b.serviceName}: could not check out ${b.branch} — ${err.message}`);
|
|
147
217
|
if (/denied|403|401|authentication|permission/i.test(err.message)) {
|
|
@@ -156,8 +226,9 @@ async function finish(config, ref, { force = false, note = null } = {}) {
|
|
|
156
226
|
if (!ref) throw new Error('Which ticket? e.g. motionode finish T11-518');
|
|
157
227
|
const api = client(config);
|
|
158
228
|
try {
|
|
229
|
+
console.log(`Checking the ticket branch and telling Motionode ${ref} is built…`);
|
|
159
230
|
const res = await api.post(`/tickets/${encodeURIComponent(ref)}/finish`, { force, note });
|
|
160
|
-
|
|
231
|
+
finishLines(res).forEach((l) => console.log(l));
|
|
161
232
|
} catch (err) {
|
|
162
233
|
console.log(`✗ ${err.message}`);
|
|
163
234
|
if (/not on the ticket branch|not pushed|uncommitted/i.test(err.message)) {
|
|
@@ -172,4 +243,4 @@ async function logout(config) {
|
|
|
172
243
|
console.log('✓ Disconnected this computer. (Remove it in Motionode Settings → Development workflow to revoke it there too.)');
|
|
173
244
|
}
|
|
174
245
|
|
|
175
|
-
module.exports = { login, link, connect, unlink, status, work, finish, logout };
|
|
246
|
+
module.exports = { login, link, connect, unlink, status, work, finish, logout, worktreePath, finishLines, cloneGuidance };
|
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 };
|