@motionode/cli 0.1.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 +24 -0
- package/bin/motionode.js +74 -0
- package/lib/api.js +50 -0
- package/lib/bridge.js +121 -0
- package/lib/commands.js +175 -0
- package/lib/config.js +46 -0
- package/lib/git.js +181 -0
- package/package.json +20 -0
package/README.md
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# @motionode/cli
|
|
2
|
+
|
|
3
|
+
Work Motionode tickets with your own tools — Cursor, VS Code, a terminal —
|
|
4
|
+
while Motionode keeps the work tied to the ticket, its git branches,
|
|
5
|
+
verification and delivery.
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npm install -g @motionode/cli
|
|
9
|
+
cd path/to/your/clone
|
|
10
|
+
motionode connect # approve this computer in the browser, link this repo
|
|
11
|
+
motionode dev # keep running: Motionode sees branches and local changes
|
|
12
|
+
motionode work T11-518 # start the ticket locally; its branch is checked out
|
|
13
|
+
# …edit, commit and push with your own git…
|
|
14
|
+
motionode finish T11-518 # Motionode checks your pushed commits and moves the ticket on
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
- Git stays yours: fetch, commit and push use your own credentials, and commits
|
|
18
|
+
carry your own name and email.
|
|
19
|
+
- The CLI only talks to Motionode over outbound HTTPS; it opens no ports.
|
|
20
|
+
- It runs only fixed git operations Motionode can ask for (check out a ticket
|
|
21
|
+
branch, show a diff, commit and push, move changes onto a ticket branch) —
|
|
22
|
+
never arbitrary commands.
|
|
23
|
+
- `--api URL` (or `MOTIONODE_API_URL`) points it at another Motionode API.
|
|
24
|
+
- Revoke a computer in Motionode → Settings → Development workflow.
|
package/bin/motionode.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const Config = require('../lib/config');
|
|
5
|
+
const Commands = require('../lib/commands');
|
|
6
|
+
const Bridge = require('../lib/bridge');
|
|
7
|
+
const { version } = require('../lib/api');
|
|
8
|
+
|
|
9
|
+
const HELP = `motionode ${version} — work Motionode tickets with your own local tools
|
|
10
|
+
|
|
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
|
|
13
|
+
motionode unlink unlink the current folder
|
|
14
|
+
motionode dev keep Motionode in sync (branches, local changes, commit requests)
|
|
15
|
+
motionode work <ticket> start a ticket locally and check out its branches (e.g. T11-518)
|
|
16
|
+
motionode finish <ticket> [--force] [--note] you're done: Motionode checks your pushed commits and moves it on
|
|
17
|
+
motionode status what each linked folder has checked out and changed
|
|
18
|
+
motionode logout disconnect this computer
|
|
19
|
+
|
|
20
|
+
Git stays yours: fetch, commit and push use your own git credentials and identity.`;
|
|
21
|
+
|
|
22
|
+
function flags(argv) {
|
|
23
|
+
const out = { _: [] };
|
|
24
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
25
|
+
const a = argv[i];
|
|
26
|
+
if (a.startsWith('--')) {
|
|
27
|
+
const key = a.slice(2);
|
|
28
|
+
const next = argv[i + 1];
|
|
29
|
+
if (next && !next.startsWith('--')) { out[key] = next; i += 1; } else { out[key] = true; }
|
|
30
|
+
} else {
|
|
31
|
+
out._.push(a);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function main() {
|
|
38
|
+
const [command, ...rest] = process.argv.slice(2);
|
|
39
|
+
const args = flags(rest);
|
|
40
|
+
const config = Config.load();
|
|
41
|
+
if (args.api) config.apiUrl = String(args.api).replace(/\/+$/, '');
|
|
42
|
+
switch (command) {
|
|
43
|
+
case 'connect':
|
|
44
|
+
return Commands.connect(config, { apiUrl: args.api || null, name: args.name || null, service: args.service || null, relogin: !!args.relogin });
|
|
45
|
+
case 'login':
|
|
46
|
+
return Commands.login(config, { apiUrl: args.api || null, name: args.name || null });
|
|
47
|
+
case 'link':
|
|
48
|
+
return Commands.link(config, { serviceUuid: args.service || null });
|
|
49
|
+
case 'unlink':
|
|
50
|
+
return Commands.unlink(config);
|
|
51
|
+
case 'dev':
|
|
52
|
+
return Bridge.run(config);
|
|
53
|
+
case 'work':
|
|
54
|
+
return Commands.work(config, args._[0]);
|
|
55
|
+
case 'finish':
|
|
56
|
+
return Commands.finish(config, args._[0], { force: !!args.force, note: typeof args.note === 'string' ? args.note : null });
|
|
57
|
+
case 'status':
|
|
58
|
+
return Commands.status(config);
|
|
59
|
+
case 'logout':
|
|
60
|
+
return Commands.logout(config);
|
|
61
|
+
case '--version':
|
|
62
|
+
case '-v':
|
|
63
|
+
console.log(version);
|
|
64
|
+
return null;
|
|
65
|
+
default:
|
|
66
|
+
console.log(HELP);
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
main().catch((err) => {
|
|
72
|
+
console.error(`✗ ${err.message}`);
|
|
73
|
+
process.exit(1);
|
|
74
|
+
});
|
package/lib/api.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/** Outbound HTTPS to Motionode's /api/v1/local-dev — the only channel. */
|
|
4
|
+
|
|
5
|
+
const pkg = require('../package.json');
|
|
6
|
+
|
|
7
|
+
class ApiError extends Error {
|
|
8
|
+
constructor(message, status) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.status = status;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function client(config) {
|
|
15
|
+
const base = String(config.apiUrl || '').replace(/\/+$/, '');
|
|
16
|
+
async function request(method, pathname, body = null, { timeoutMs = 60000 } = {}) {
|
|
17
|
+
const controller = new AbortController();
|
|
18
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
19
|
+
let resp;
|
|
20
|
+
try {
|
|
21
|
+
resp = await fetch(`${base}/api/v1/local-dev${pathname}`, {
|
|
22
|
+
method,
|
|
23
|
+
headers: {
|
|
24
|
+
'Content-Type': 'application/json',
|
|
25
|
+
'User-Agent': `motionode-cli/${pkg.version}`,
|
|
26
|
+
...(config.token ? { Authorization: `Bearer ${config.token}` } : {}),
|
|
27
|
+
},
|
|
28
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
29
|
+
signal: controller.signal,
|
|
30
|
+
});
|
|
31
|
+
} catch (err) {
|
|
32
|
+
if (err.name === 'AbortError') throw new ApiError('Motionode did not answer in time.', 0);
|
|
33
|
+
throw new ApiError(`Could not reach Motionode at ${base}: ${err.message}`, 0);
|
|
34
|
+
} finally {
|
|
35
|
+
clearTimeout(timer);
|
|
36
|
+
}
|
|
37
|
+
let data = null;
|
|
38
|
+
try { data = await resp.json(); } catch (_e) { data = null; }
|
|
39
|
+
if (!resp.ok || data?.ok === false) {
|
|
40
|
+
throw new ApiError(data?.error || `Motionode answered ${resp.status}.`, resp.status);
|
|
41
|
+
}
|
|
42
|
+
return data || {};
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
get: (p, opts) => request('GET', p, null, opts),
|
|
46
|
+
post: (p, body, opts) => request('POST', p, body || {}, opts),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
module.exports = { client, ApiError, version: pkg.version };
|
package/lib/bridge.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `motionode dev` — keeps Motionode in sync with this machine's linked
|
|
5
|
+
* repositories: every few seconds it reports each folder's branch and what is
|
|
6
|
+
* changed but not committed, and it runs the jobs Motionode asks for (check out
|
|
7
|
+
* a ticket branch, show a diff, commit and push, move changes to a ticket
|
|
8
|
+
* branch). Only those fixed git operations exist — no arbitrary commands.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const Git = require('./git');
|
|
12
|
+
const { client } = require('./api');
|
|
13
|
+
const { version } = require('./api');
|
|
14
|
+
|
|
15
|
+
const HEARTBEAT_MS = 10000;
|
|
16
|
+
|
|
17
|
+
function log(...args) {
|
|
18
|
+
const t = new Date().toLocaleTimeString();
|
|
19
|
+
console.log(`[${t}]`, ...args);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function collect(config) {
|
|
23
|
+
const out = [];
|
|
24
|
+
for (const ws of config.workspaces || []) {
|
|
25
|
+
try {
|
|
26
|
+
const st = await Git.status(ws.path);
|
|
27
|
+
out.push({
|
|
28
|
+
serviceUuid: ws.serviceUuid,
|
|
29
|
+
path: ws.path,
|
|
30
|
+
remoteUrl: await Git.remoteUrl(ws.path),
|
|
31
|
+
branch: st.branch,
|
|
32
|
+
headSha: st.headSha,
|
|
33
|
+
ahead: st.ahead,
|
|
34
|
+
behind: st.behind,
|
|
35
|
+
dirtyFiles: st.dirtyFiles,
|
|
36
|
+
});
|
|
37
|
+
} catch (err) {
|
|
38
|
+
out.push({ serviceUuid: ws.serviceUuid, path: ws.path, gitError: err.message });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function workspaceFor(config, serviceUuid) {
|
|
45
|
+
return (config.workspaces || []).find((w) => w.serviceUuid === serviceUuid) || null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function runJob(config, job) {
|
|
49
|
+
const payload = job.payload || {};
|
|
50
|
+
const ws = workspaceFor(config, payload.serviceUuid);
|
|
51
|
+
if (!ws) throw new Error(`No folder on this computer is linked to ${payload.serviceName || 'that repository'}. Run \`motionode link\` in it.`);
|
|
52
|
+
if (job.kind === 'checkout') {
|
|
53
|
+
const st = await Git.checkout(ws.path, payload.branch);
|
|
54
|
+
log(`checked out ${payload.branch} in ${ws.path}`);
|
|
55
|
+
return { branch: st.branch, headSha: st.headSha };
|
|
56
|
+
}
|
|
57
|
+
if (job.kind === 'diff') {
|
|
58
|
+
return Git.diff(ws.path, payload.path || null);
|
|
59
|
+
}
|
|
60
|
+
if (job.kind === 'commit') {
|
|
61
|
+
const res = await Git.commit(ws.path, { branch: payload.branch, message: payload.message, push: payload.push !== false });
|
|
62
|
+
log(res.nothingToCommit ? `nothing to commit in ${ws.path}${res.pushed ? ' (pushed)' : ''}` : `committed ${String(res.commitSha).slice(0, 7)} in ${ws.path}${res.pushed ? ' and pushed' : ''}`);
|
|
63
|
+
return res;
|
|
64
|
+
}
|
|
65
|
+
if (job.kind === 'move_changes') {
|
|
66
|
+
const res = await Git.moveChanges(ws.path, payload.branch);
|
|
67
|
+
log(`moved local changes onto ${payload.branch} in ${ws.path}`);
|
|
68
|
+
return res;
|
|
69
|
+
}
|
|
70
|
+
throw new Error(`Unknown job: ${job.kind}`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function handleJobs(config, api, jobs) {
|
|
74
|
+
for (const job of jobs || []) {
|
|
75
|
+
try {
|
|
76
|
+
const result = await runJob(config, job);
|
|
77
|
+
await api.post(`/jobs/${job.uuid}/result`, { ok: true, result });
|
|
78
|
+
} catch (err) {
|
|
79
|
+
log(`job ${job.kind} failed: ${err.message}`);
|
|
80
|
+
await api.post(`/jobs/${job.uuid}/result`, { ok: false, error: err.message }).catch(() => {});
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function run(config) {
|
|
86
|
+
if (!config.token) throw new Error('This computer is not connected. Run `motionode connect` first.');
|
|
87
|
+
if (!(config.workspaces || []).length) throw new Error('No folders are linked yet. Run `motionode link` inside a repository.');
|
|
88
|
+
const api = client(config);
|
|
89
|
+
log(`Motionode bridge running for ${config.machine?.name || 'this computer'} — ${config.workspaces.length} linked folder(s). Ctrl+C to stop.`);
|
|
90
|
+
let stopped = false;
|
|
91
|
+
process.on('SIGINT', () => { stopped = true; log('stopping'); process.exit(0); });
|
|
92
|
+
|
|
93
|
+
const beat = async () => {
|
|
94
|
+
try {
|
|
95
|
+
const res = await api.post('/heartbeat', { cliVersion: version, workspaces: await collect(config) });
|
|
96
|
+
await handleJobs(config, api, res.jobs);
|
|
97
|
+
} catch (err) {
|
|
98
|
+
log(`heartbeat failed: ${err.message}`);
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
await beat();
|
|
102
|
+
const timer = setInterval(beat, HEARTBEAT_MS);
|
|
103
|
+
// Long-poll so jobs (a checkout, a commit) start within a second.
|
|
104
|
+
while (!stopped) {
|
|
105
|
+
try {
|
|
106
|
+
const res = await api.get('/jobs?wait=25', { timeoutMs: 40000 });
|
|
107
|
+
if (res.jobs?.length) {
|
|
108
|
+
await handleJobs(config, api, res.jobs);
|
|
109
|
+
await beat();
|
|
110
|
+
}
|
|
111
|
+
} catch (err) {
|
|
112
|
+
if (err.status === 401) {
|
|
113
|
+
clearInterval(timer);
|
|
114
|
+
throw new Error('This computer was disconnected from Motionode. Run `motionode connect` again.');
|
|
115
|
+
}
|
|
116
|
+
await new Promise((r) => setTimeout(r, 5000));
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
module.exports = { run, collect };
|
package/lib/commands.js
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const os = require('os');
|
|
4
|
+
const readline = require('readline');
|
|
5
|
+
const { execFile } = require('child_process');
|
|
6
|
+
const Config = require('./config');
|
|
7
|
+
const Git = require('./git');
|
|
8
|
+
const { client, version } = require('./api');
|
|
9
|
+
|
|
10
|
+
function ask(question) {
|
|
11
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
12
|
+
return new Promise((resolve) => rl.question(question, (answer) => { rl.close(); resolve(answer.trim()); }));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function openBrowser(url) {
|
|
16
|
+
const cmd = process.platform === 'win32' ? 'cmd' : (process.platform === 'darwin' ? 'open' : 'xdg-open');
|
|
17
|
+
const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
|
|
18
|
+
execFile(cmd, args, { windowsHide: true }, () => {});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
22
|
+
|
|
23
|
+
/** Device-code login: approve this computer in the browser once. */
|
|
24
|
+
async function login(config, { apiUrl = null, name = null } = {}) {
|
|
25
|
+
if (apiUrl) config.apiUrl = apiUrl.replace(/\/+$/, '');
|
|
26
|
+
const api = client({ ...config, token: null });
|
|
27
|
+
const machineName = name || os.hostname();
|
|
28
|
+
const start = await api.post('/device/start', { machineName, platform: `${process.platform}-${process.arch}` });
|
|
29
|
+
console.log('');
|
|
30
|
+
console.log(` Approve this computer in Motionode: ${start.verificationUrl}`);
|
|
31
|
+
console.log(` Code: ${start.userCode}`);
|
|
32
|
+
console.log('');
|
|
33
|
+
openBrowser(start.verificationUrl);
|
|
34
|
+
const until = Date.now() + start.expiresIn * 1000;
|
|
35
|
+
while (Date.now() < until) {
|
|
36
|
+
await sleep((start.interval || 3) * 1000);
|
|
37
|
+
const poll = await api.post('/device/poll', { deviceCode: start.deviceCode, cliVersion: version });
|
|
38
|
+
if (poll.status === 'approved') {
|
|
39
|
+
config.token = poll.token;
|
|
40
|
+
config.machine = poll.machine;
|
|
41
|
+
config.user = poll.user;
|
|
42
|
+
Config.save(config);
|
|
43
|
+
console.log(`✓ Connected as ${poll.user?.name || poll.user?.email || 'you'} (${poll.machine?.name}).`);
|
|
44
|
+
return config;
|
|
45
|
+
}
|
|
46
|
+
if (poll.status === 'denied') throw new Error('The connection was declined in the browser.');
|
|
47
|
+
if (poll.status === 'expired' || poll.status === 'invalid') throw new Error('The code expired. Run `motionode connect` again.');
|
|
48
|
+
}
|
|
49
|
+
throw new Error('The code expired. Run `motionode connect` again.');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** 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() } = {}) {
|
|
54
|
+
const root = await Git.isRepo(dir);
|
|
55
|
+
if (!root) throw new Error('This folder is not a git repository. Run `motionode link` inside your clone.');
|
|
56
|
+
const api = client(config);
|
|
57
|
+
const me = await api.get('/me');
|
|
58
|
+
const services = [];
|
|
59
|
+
for (const project of me.projects || []) {
|
|
60
|
+
for (const svc of project.services || []) services.push({ ...svc, projectUuid: project.uuid, projectName: project.name });
|
|
61
|
+
}
|
|
62
|
+
if (!services.length) throw new Error('You have no projects with AI building enabled to work on locally.');
|
|
63
|
+
let pick = serviceUuid ? services.find((s) => s.uuid === serviceUuid) : null;
|
|
64
|
+
if (!pick) {
|
|
65
|
+
const remote = Git.normalizeRepoUrl(await Git.remoteUrl(root));
|
|
66
|
+
const matches = remote ? services.filter((s) => Git.normalizeRepoUrl(s.repoUrl) === remote) : [];
|
|
67
|
+
if (matches.length === 1) pick = matches[0];
|
|
68
|
+
else {
|
|
69
|
+
const list = matches.length ? matches : services;
|
|
70
|
+
if (!matches.length && remote) console.log(`No Motionode repository has this folder's remote (${remote}). Choose one:`);
|
|
71
|
+
list.forEach((s, i) => console.log(` ${i + 1}) ${s.projectName} → ${s.name}${s.repoUrl ? ` (${s.repoUrl})` : ''}`));
|
|
72
|
+
const answer = Number(await ask('Number: '));
|
|
73
|
+
pick = list[answer - 1];
|
|
74
|
+
if (!pick) throw new Error('Nothing linked.');
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
config.workspaces = (config.workspaces || []).filter((w) => !Config.samePath(w.path, root) && w.serviceUuid !== pick.uuid);
|
|
78
|
+
config.workspaces.push({
|
|
79
|
+
path: root,
|
|
80
|
+
serviceUuid: pick.uuid,
|
|
81
|
+
serviceName: pick.name,
|
|
82
|
+
projectUuid: pick.projectUuid,
|
|
83
|
+
projectName: pick.projectName,
|
|
84
|
+
});
|
|
85
|
+
Config.save(config);
|
|
86
|
+
console.log(`✓ ${root} → ${pick.projectName} / ${pick.name}`);
|
|
87
|
+
return pick;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function connect(config, opts = {}) {
|
|
91
|
+
if (!config.token || opts.relogin) await login(config, opts);
|
|
92
|
+
if (await Git.isRepo(process.cwd())) await link(config, { serviceUuid: opts.service || null });
|
|
93
|
+
console.log('');
|
|
94
|
+
console.log('Next: keep `motionode dev` running so Motionode sees your branches and local changes,');
|
|
95
|
+
console.log('and use `motionode work <ticket>` to start a ticket (e.g. motionode work T11-518).');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function unlink(config, { dir = process.cwd() } = {}) {
|
|
99
|
+
const root = (await Git.isRepo(dir)) || dir;
|
|
100
|
+
const before = (config.workspaces || []).length;
|
|
101
|
+
config.workspaces = (config.workspaces || []).filter((w) => !Config.samePath(w.path, root));
|
|
102
|
+
Config.save(config);
|
|
103
|
+
console.log(before === config.workspaces.length ? 'This folder was not linked.' : `✓ Unlinked ${root}`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function status(config) {
|
|
107
|
+
if (!config.token) {
|
|
108
|
+
console.log('Not connected. Run `motionode connect`.');
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
console.log(`Motionode: ${config.apiUrl} — ${config.user?.name || config.user?.email || ''} on ${config.machine?.name || os.hostname()}`);
|
|
112
|
+
if (!(config.workspaces || []).length) {
|
|
113
|
+
console.log('No linked folders. Run `motionode link` inside a repository.');
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
for (const ws of config.workspaces) {
|
|
117
|
+
try {
|
|
118
|
+
const st = await Git.status(ws.path);
|
|
119
|
+
const ticket = /^ai\/ticket-/.test(st.branch || '') ? ` ticket ${st.branch.replace(/^ai\/ticket-/, '')}` : '';
|
|
120
|
+
console.log(`\n${ws.projectName} / ${ws.serviceName}\n ${ws.path}\n branch ${st.branch || '(none)'}${ticket}`);
|
|
121
|
+
console.log(` ${st.dirtyFiles.length} uncommitted file(s)${st.ahead ? `, ${st.ahead} commit(s) not pushed` : ''}${st.behind ? `, ${st.behind} behind` : ''}`);
|
|
122
|
+
for (const f of st.dirtyFiles.slice(0, 15)) console.log(` ${f.status.padEnd(8)} ${f.path}`);
|
|
123
|
+
} catch (err) {
|
|
124
|
+
console.log(`\n${ws.projectName} / ${ws.serviceName}\n ${ws.path}\n git: ${err.message}`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Start (or resume) a ticket locally and check its branches out here. */
|
|
130
|
+
async function work(config, ref) {
|
|
131
|
+
if (!ref) throw new Error('Which ticket? e.g. motionode work T11-518');
|
|
132
|
+
const api = client(config);
|
|
133
|
+
const res = await api.post(`/tickets/${encodeURIComponent(ref)}/work`, {});
|
|
134
|
+
const lw = res.localWork || {};
|
|
135
|
+
console.log(`✓ ${lw.ticketDisplayId || ref} ${lw.ticketTitle || ''} — Build is yours, working locally.`);
|
|
136
|
+
for (const b of lw.branches || []) {
|
|
137
|
+
const ws = (config.workspaces || []).find((w) => w.serviceUuid === b.serviceUuid);
|
|
138
|
+
if (!ws) {
|
|
139
|
+
console.log(` ${b.serviceName}: ${b.branch} (no linked folder here — clone ${b.cloneUrl || 'the repo'} and run \`motionode link\`)`);
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
try {
|
|
143
|
+
await Git.checkout(ws.path, b.branch);
|
|
144
|
+
console.log(` ${b.serviceName}: checked out ${b.branch} in ${ws.path}`);
|
|
145
|
+
} catch (err) {
|
|
146
|
+
console.log(` ${b.serviceName}: could not check out ${b.branch} — ${err.message}`);
|
|
147
|
+
if (/denied|403|401|authentication|permission/i.test(err.message)) {
|
|
148
|
+
console.log(' Your git account has no access to this repository. Ask your project manager for access.');
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
console.log('\nCommit with your own git and push to the ticket branch. When you are done: motionode finish ' + (lw.ticketDisplayId || ref));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function finish(config, ref, { force = false, note = null } = {}) {
|
|
156
|
+
if (!ref) throw new Error('Which ticket? e.g. motionode finish T11-518');
|
|
157
|
+
const api = client(config);
|
|
158
|
+
try {
|
|
159
|
+
const res = await api.post(`/tickets/${encodeURIComponent(ref)}/finish`, { force, note });
|
|
160
|
+
console.log(`✓ ${res.summary || 'Build finished.'}`);
|
|
161
|
+
} catch (err) {
|
|
162
|
+
console.log(`✗ ${err.message}`);
|
|
163
|
+
if (/not on the ticket branch|not pushed|uncommitted/i.test(err.message)) {
|
|
164
|
+
console.log(' Commit and push (git commit, git push), or run with --force to finish anyway.');
|
|
165
|
+
}
|
|
166
|
+
process.exitCode = 1;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function logout(config) {
|
|
171
|
+
Config.save({ apiUrl: config.apiUrl, token: null, machine: null, user: null, workspaces: config.workspaces || [] });
|
|
172
|
+
console.log('✓ Disconnected this computer. (Remove it in Motionode Settings → Development workflow to revoke it there too.)');
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
module.exports = { login, link, connect, unlink, status, work, finish, logout };
|
package/lib/config.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ~/.motionode/config.json — the machine token, the API it talks to, and which
|
|
5
|
+
* local folders are which Motionode repositories. Folder paths never leave this
|
|
6
|
+
* machine except as the label shown to their owner in Motionode.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const os = require('os');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
|
|
13
|
+
const DIR = process.env.MOTIONODE_HOME || path.join(os.homedir(), '.motionode');
|
|
14
|
+
const FILE = path.join(DIR, 'config.json');
|
|
15
|
+
const DEFAULT_API = process.env.MOTIONODE_API_URL || 'https://api.motionode.com';
|
|
16
|
+
|
|
17
|
+
function load() {
|
|
18
|
+
try {
|
|
19
|
+
const raw = JSON.parse(fs.readFileSync(FILE, 'utf8'));
|
|
20
|
+
return {
|
|
21
|
+
apiUrl: raw.apiUrl || DEFAULT_API,
|
|
22
|
+
token: raw.token || null,
|
|
23
|
+
machine: raw.machine || null,
|
|
24
|
+
user: raw.user || null,
|
|
25
|
+
workspaces: Array.isArray(raw.workspaces) ? raw.workspaces : [],
|
|
26
|
+
};
|
|
27
|
+
} catch (_e) {
|
|
28
|
+
return { apiUrl: DEFAULT_API, token: null, machine: null, user: null, workspaces: [] };
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function save(config) {
|
|
33
|
+
fs.mkdirSync(DIR, { recursive: true });
|
|
34
|
+
fs.writeFileSync(FILE, JSON.stringify(config, null, 2), { mode: 0o600 });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function samePath(a, b) {
|
|
38
|
+
const norm = (p) => path.resolve(p).replace(/[\\/]+$/, '');
|
|
39
|
+
return process.platform === 'win32' ? norm(a).toLowerCase() === norm(b).toLowerCase() : norm(a) === norm(b);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function workspaceForPath(config, dir) {
|
|
43
|
+
return (config.workspaces || []).find((w) => samePath(w.path, dir)) || null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
module.exports = { DIR, FILE, DEFAULT_API, load, save, samePath, workspaceForPath };
|
package/lib/git.js
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The fixed git operations the CLI runs — nothing else. Everything uses the
|
|
5
|
+
* developer's own git: their credentials (credential helper / SSH keys) to
|
|
6
|
+
* fetch and push, and their own user.name / user.email on commits.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const { execFile } = require('child_process');
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
|
|
13
|
+
const MAX_DIFF_CHARS = 400000;
|
|
14
|
+
|
|
15
|
+
function run(cwd, args, { allowFail = false, timeoutMs = 120000 } = {}) {
|
|
16
|
+
return new Promise((resolve, reject) => {
|
|
17
|
+
execFile('git', args, { cwd, timeout: timeoutMs, maxBuffer: 64 * 1024 * 1024, windowsHide: true }, (err, stdout, stderr) => {
|
|
18
|
+
if (err && !allowFail) {
|
|
19
|
+
const message = String(stderr || err.message || '').trim() || `git ${args[0]} failed`;
|
|
20
|
+
const e = new Error(message);
|
|
21
|
+
e.stderr = stderr;
|
|
22
|
+
return reject(e);
|
|
23
|
+
}
|
|
24
|
+
return resolve({ ok: !err, stdout: String(stdout || ''), stderr: String(stderr || '') });
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function isRepo(dir) {
|
|
30
|
+
const r = await run(dir, ['rev-parse', '--show-toplevel'], { allowFail: true });
|
|
31
|
+
return r.ok ? r.stdout.trim() : null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function remoteUrl(dir) {
|
|
35
|
+
const r = await run(dir, ['remote', 'get-url', 'origin'], { allowFail: true });
|
|
36
|
+
return r.ok ? r.stdout.trim() : null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** github.com/owner/repo from https / ssh / credentials-in-URL forms. */
|
|
40
|
+
function normalizeRepoUrl(url) {
|
|
41
|
+
let u = String(url || '').trim();
|
|
42
|
+
if (!u) return null;
|
|
43
|
+
u = u.replace(/^git@([^:]+):/, 'https://$1/');
|
|
44
|
+
u = u.replace(/^ssh:\/\/git@/, 'https://');
|
|
45
|
+
u = u.replace(/^https?:\/\/[^@/]+@/, 'https://');
|
|
46
|
+
u = u.replace(/^https?:\/\//, '');
|
|
47
|
+
u = u.replace(/\.git$/, '').replace(/\/+$/, '');
|
|
48
|
+
return u.toLowerCase();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Branch, head, ahead/behind and the files changed but not committed. */
|
|
52
|
+
async function status(dir) {
|
|
53
|
+
const s = await run(dir, ['status', '--porcelain=v1', '-b', '--untracked-files=all']);
|
|
54
|
+
const lines = s.stdout.split('\n').filter(Boolean);
|
|
55
|
+
let branch = null;
|
|
56
|
+
let ahead = 0;
|
|
57
|
+
let behind = 0;
|
|
58
|
+
const files = [];
|
|
59
|
+
for (const line of lines) {
|
|
60
|
+
if (line.startsWith('## ')) {
|
|
61
|
+
const head = line.slice(3);
|
|
62
|
+
const m = head.match(/^([^.\s]+(?:\.[^.\s]+)*?)(?:\.\.\.(\S+))?(?:\s+\[(.*)\])?$/);
|
|
63
|
+
branch = m ? m[1] : head.split(/\s|\.\.\./)[0];
|
|
64
|
+
if (branch === 'HEAD' || /^No commits yet on /.test(head)) branch = head.replace(/^No commits yet on /, '') || null;
|
|
65
|
+
const info = m?.[3] || '';
|
|
66
|
+
const a = info.match(/ahead (\d+)/);
|
|
67
|
+
const b = info.match(/behind (\d+)/);
|
|
68
|
+
ahead = a ? Number(a[1]) : 0;
|
|
69
|
+
behind = b ? Number(b[1]) : 0;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
const code = line.slice(0, 2);
|
|
73
|
+
const file = line.slice(3).replace(/^"|"$/g, '').split(' -> ').pop();
|
|
74
|
+
const kind = code === '??' ? 'added'
|
|
75
|
+
: code.includes('D') ? 'removed'
|
|
76
|
+
: code.includes('A') ? 'added'
|
|
77
|
+
: code.includes('R') ? 'renamed'
|
|
78
|
+
: 'modified';
|
|
79
|
+
files.push({ path: file, status: kind, additions: 0, deletions: 0 });
|
|
80
|
+
}
|
|
81
|
+
// +/- for tracked files.
|
|
82
|
+
const num = await run(dir, ['diff', 'HEAD', '--numstat'], { allowFail: true });
|
|
83
|
+
if (num.ok) {
|
|
84
|
+
const stats = new Map();
|
|
85
|
+
for (const l of num.stdout.split('\n').filter(Boolean)) {
|
|
86
|
+
const [add, del, ...rest] = l.split('\t');
|
|
87
|
+
stats.set(rest.join('\t'), { additions: Number(add) || 0, deletions: Number(del) || 0 });
|
|
88
|
+
}
|
|
89
|
+
for (const f of files) {
|
|
90
|
+
const st = stats.get(f.path);
|
|
91
|
+
if (st) Object.assign(f, st);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const headSha = (await run(dir, ['rev-parse', 'HEAD'], { allowFail: true })).stdout.trim() || null;
|
|
95
|
+
return { branch, headSha, ahead, behind, dirtyFiles: files };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function hasLocalBranch(dir, branch) {
|
|
99
|
+
const r = await run(dir, ['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`], { allowFail: true });
|
|
100
|
+
return r.ok;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Fetch and switch to a branch (creating it to track origin when missing). Uncommitted changes come along when git allows. */
|
|
104
|
+
async function checkout(dir, branch) {
|
|
105
|
+
await run(dir, ['fetch', 'origin', branch]);
|
|
106
|
+
if (await hasLocalBranch(dir, branch)) {
|
|
107
|
+
await run(dir, ['switch', branch]);
|
|
108
|
+
await run(dir, ['merge', '--ff-only', `origin/${branch}`], { allowFail: true });
|
|
109
|
+
} else {
|
|
110
|
+
await run(dir, ['switch', '-c', branch, '--track', `origin/${branch}`]);
|
|
111
|
+
}
|
|
112
|
+
return status(dir);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The uncommitted diff, including new files. */
|
|
116
|
+
async function diff(dir, onlyPath = null) {
|
|
117
|
+
const args = ['diff', 'HEAD'];
|
|
118
|
+
if (onlyPath) args.push('--', onlyPath);
|
|
119
|
+
const tracked = await run(dir, args, { allowFail: true });
|
|
120
|
+
let out = tracked.stdout;
|
|
121
|
+
const untracked = await run(dir, ['ls-files', '--others', '--exclude-standard'], { allowFail: true });
|
|
122
|
+
for (const file of untracked.stdout.split('\n').filter(Boolean)) {
|
|
123
|
+
if (onlyPath && file !== onlyPath) continue;
|
|
124
|
+
if (out.length > MAX_DIFF_CHARS) break;
|
|
125
|
+
let content = '';
|
|
126
|
+
try {
|
|
127
|
+
const full = path.join(dir, file);
|
|
128
|
+
if (fs.statSync(full).size > 512 * 1024) { content = '(large file not shown)'; } else { content = fs.readFileSync(full, 'utf8'); }
|
|
129
|
+
} catch (_e) { content = '(unreadable)'; }
|
|
130
|
+
const lines = content.split(/\r?\n/);
|
|
131
|
+
out += `--- /dev/null\n+++ b/${file}\n@@ -0,0 +1,${lines.length} @@\n${lines.map((l) => `+${l}`).join('\n')}\n`;
|
|
132
|
+
}
|
|
133
|
+
const truncated = out.length > MAX_DIFF_CHARS;
|
|
134
|
+
return { diff: truncated ? out.slice(0, MAX_DIFF_CHARS) : out, truncated };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Commit everything changed on `branch` with the developer's identity; push when asked. */
|
|
138
|
+
async function commit(dir, { branch, message, push = true }) {
|
|
139
|
+
const st = await status(dir);
|
|
140
|
+
if (branch && st.branch !== branch) {
|
|
141
|
+
throw new Error(`This folder is on ${st.branch || 'no branch'}, not the ticket branch ${branch}. Check it out first (motionode work <ticket>).`);
|
|
142
|
+
}
|
|
143
|
+
let commitSha = null;
|
|
144
|
+
let nothingToCommit = false;
|
|
145
|
+
if (st.dirtyFiles.length) {
|
|
146
|
+
await run(dir, ['add', '-A']);
|
|
147
|
+
await run(dir, ['commit', '-m', message || 'Work from Motionode CLI']);
|
|
148
|
+
commitSha = (await run(dir, ['rev-parse', 'HEAD'])).stdout.trim();
|
|
149
|
+
} else {
|
|
150
|
+
nothingToCommit = true;
|
|
151
|
+
}
|
|
152
|
+
let pushed = false;
|
|
153
|
+
if (push) {
|
|
154
|
+
await run(dir, ['push', '-u', 'origin', st.branch || branch]);
|
|
155
|
+
pushed = true;
|
|
156
|
+
}
|
|
157
|
+
return { commitSha, pushed, nothingToCommit };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Carry uncommitted changes from wherever they are onto `branch`. */
|
|
161
|
+
async function moveChanges(dir, branch) {
|
|
162
|
+
const st = await status(dir);
|
|
163
|
+
if (st.branch === branch) return { moved: false, branch };
|
|
164
|
+
const dirty = st.dirtyFiles.length > 0;
|
|
165
|
+
if (dirty) await run(dir, ['stash', 'push', '-u', '-m', `motionode: move to ${branch}`]);
|
|
166
|
+
try {
|
|
167
|
+
await checkout(dir, branch);
|
|
168
|
+
} catch (err) {
|
|
169
|
+
if (dirty) await run(dir, ['stash', 'pop'], { allowFail: true });
|
|
170
|
+
throw err;
|
|
171
|
+
}
|
|
172
|
+
if (dirty) {
|
|
173
|
+
const pop = await run(dir, ['stash', 'pop'], { allowFail: true });
|
|
174
|
+
if (!pop.ok) {
|
|
175
|
+
throw new Error(`Switched to ${branch}, but your changes conflict with it. They are kept in git stash — resolve with \`git stash pop\`.`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return { moved: true, branch };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
module.exports = { run, isRepo, remoteUrl, normalizeRepoUrl, status, checkout, diff, commit, moveChanges };
|
package/package.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@motionode/cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Connect your local repositories to Motionode: work tickets in Cursor, VS Code or any editor while Motionode keeps the work tied to the ticket, its git branches, verification and delivery.",
|
|
5
|
+
"license": "UNLICENSED",
|
|
6
|
+
"bin": {
|
|
7
|
+
"motionode": "bin/motionode.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin",
|
|
11
|
+
"lib",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=18"
|
|
16
|
+
},
|
|
17
|
+
"publishConfig": {
|
|
18
|
+
"access": "public"
|
|
19
|
+
}
|
|
20
|
+
}
|