@motionode/cli 0.2.0 → 0.2.3
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 +17 -2
- package/bin/motionode.js +21 -4
- package/lib/api.js +3 -2
- package/lib/bridge.js +19 -0
- package/lib/commands.js +165 -28
- package/lib/doctor.js +88 -0
- package/lib/git.js +53 -7
- package/lib/git_credential.js +19 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -9,9 +9,9 @@ npm install -g @motionode/cli
|
|
|
9
9
|
cd path/to/your/clone
|
|
10
10
|
motionode connect # approve this computer in the browser, link this repo
|
|
11
11
|
motionode dev # keep running: Motionode sees branches and local changes
|
|
12
|
-
motionode work
|
|
12
|
+
motionode work T9-99 # start the ticket locally; its branch is checked out
|
|
13
13
|
# …edit, commit and push with your own git…
|
|
14
|
-
motionode finish
|
|
14
|
+
motionode finish T9-99 # Motionode checks your pushed commits and moves the ticket on
|
|
15
15
|
```
|
|
16
16
|
|
|
17
17
|
- Git stays yours: fetch, commit and push use your own credentials, and commits
|
|
@@ -23,6 +23,21 @@ motionode finish T11-518 # Motionode checks your pushed commits and moves the
|
|
|
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
25
|
|
|
26
|
+
## Newer commands (0.2.3)
|
|
27
|
+
|
|
28
|
+
- `motionode doctor` prints where this terminal stands in one screen (which Motionode, who is connected and for which
|
|
29
|
+
organization, git identity, remote, branch, the linked project and repository, the ticket the branch belongs to and whether local
|
|
30
|
+
work is open) and the next command to run. Paste it to Mo or your project manager when something does not work.
|
|
31
|
+
- `motionode work <ticket> [--project NAME]` / `motionode finish <ticket> [--project NAME]`: a ticket code is only unique inside one
|
|
32
|
+
organization. The code resolves inside your own projects, preferring the project of the folder you are in; when two of your
|
|
33
|
+
projects share it, the answer lists them and you pick with `--project` or the ticket id. The output always names the ticket and
|
|
34
|
+
the project it acted on.
|
|
35
|
+
- `motionode work` ends with "Ready" or "Not ready"; it never reports success before the branch is checked out. After a step was
|
|
36
|
+
finished, `motionode work` reopens a session so you can keep pushing fixes, and `motionode finish` records them as a new version.
|
|
37
|
+
- `motionode finish <ticket> --no-changes --note "why"` finishes your step when the work already exists and nothing needs to
|
|
38
|
+
change. `finish` only ever completes a step you hold and started.
|
|
39
|
+
- `--help` (also `connect --help`) prints help and never starts a login.
|
|
40
|
+
|
|
26
41
|
## Repositories Motionode hosts
|
|
27
42
|
|
|
28
43
|
A repository Motionode hosts lives privately in Motionode's GitHub, so your own
|
package/bin/motionode.js
CHANGED
|
@@ -15,10 +15,13 @@ const HELP = `motionode ${version} — work Motionode tickets with your own loca
|
|
|
15
15
|
motionode clone <service | owner/repo> [dir] clone a Motionode-hosted repository through Motionode (no GitHub
|
|
16
16
|
access needed: this computer's connection signs git in)
|
|
17
17
|
motionode dev keep Motionode in sync (branches, local changes, commit requests)
|
|
18
|
-
motionode work <ticket>
|
|
19
|
-
|
|
18
|
+
motionode work <ticket> [--project P] start a ticket locally and check out its branches (e.g. T9-99); a code
|
|
19
|
+
shared by several projects resolves to this folder's project, or --project
|
|
20
|
+
motionode finish <ticket> [--force] [--note] you're done: Motionode checks your pushed commits and completes YOUR step
|
|
21
|
+
[--no-changes] (nothing needs to change: the work already exists; give a --note why)
|
|
20
22
|
motionode status what each linked folder has checked out and changed
|
|
21
23
|
motionode where which Motionode this terminal talks to
|
|
24
|
+
motionode doctor where this terminal stands (API, person, git identity, branch, ticket) and what to run next
|
|
22
25
|
motionode use <url|production> switch which Motionode the CLI talks to (remembered)
|
|
23
26
|
motionode logout disconnect this computer
|
|
24
27
|
|
|
@@ -64,6 +67,18 @@ function flags(argv) {
|
|
|
64
67
|
async function main() {
|
|
65
68
|
const [command, ...rest] = process.argv.slice(2);
|
|
66
69
|
const args = flags(rest);
|
|
70
|
+
// Help is printed before anything else runs: `connect --help` must never start a login.
|
|
71
|
+
if (!command || command === 'help' || command === '--help' || command === '-h' || args.help || rest.includes('-h')) {
|
|
72
|
+
console.log(HELP);
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* A server named for `connect` is the one this computer keeps using: the work
|
|
77
|
+
* panel prints `motionode connect --api …` once, and every command after it
|
|
78
|
+
* must stay on that Motionode rather than fall back to production
|
|
79
|
+
* (QA-20261023T234000Z).
|
|
80
|
+
*/
|
|
81
|
+
if (command === 'connect' && typeof args.api === 'string') Config.useApi(args.api);
|
|
67
82
|
// --api (or MOTIONODE_API_URL) picks the Motionode — and its own saved connection.
|
|
68
83
|
const config = Config.load(typeof args.api === 'string' ? args.api : null);
|
|
69
84
|
switch (command) {
|
|
@@ -83,9 +98,9 @@ async function main() {
|
|
|
83
98
|
case 'dev':
|
|
84
99
|
return Bridge.run(config);
|
|
85
100
|
case 'work':
|
|
86
|
-
return Commands.work(config, args._[0]);
|
|
101
|
+
return Commands.work(config, args._[0], { project: typeof args.project === 'string' ? args.project : null });
|
|
87
102
|
case 'finish':
|
|
88
|
-
return Commands.finish(config, args._[0], { force: !!args.force, note: typeof args.note === 'string' ? args.note : null });
|
|
103
|
+
return Commands.finish(config, args._[0], { force: !!args.force, noChanges: !!args['no-changes'], note: typeof args.note === 'string' ? args.note : null, project: typeof args.project === 'string' ? args.project : null });
|
|
89
104
|
case 'status':
|
|
90
105
|
return Commands.status(config);
|
|
91
106
|
case 'use': {
|
|
@@ -94,6 +109,8 @@ async function main() {
|
|
|
94
109
|
console.log(`✓ Using ${api}${next.token ? '' : ' — run "motionode connect" in a repository folder to connect this computer to it'}`);
|
|
95
110
|
return null;
|
|
96
111
|
}
|
|
112
|
+
case 'doctor':
|
|
113
|
+
return require('../lib/doctor').doctor(config);
|
|
97
114
|
case 'where':
|
|
98
115
|
console.log(`${config.apiUrl}${config.token ? ` — connected as ${config.user?.name || config.user?.email || 'you'} (${config.machine?.name || 'this computer'})` : ' — not connected'}`);
|
|
99
116
|
return null;
|
package/lib/api.js
CHANGED
|
@@ -5,9 +5,10 @@
|
|
|
5
5
|
const pkg = require('../package.json');
|
|
6
6
|
|
|
7
7
|
class ApiError extends Error {
|
|
8
|
-
constructor(message, status) {
|
|
8
|
+
constructor(message, status, data = null) {
|
|
9
9
|
super(message);
|
|
10
10
|
this.status = status;
|
|
11
|
+
this.data = data;
|
|
11
12
|
}
|
|
12
13
|
}
|
|
13
14
|
|
|
@@ -37,7 +38,7 @@ function client(config) {
|
|
|
37
38
|
let data = null;
|
|
38
39
|
try { data = await resp.json(); } catch (_e) { data = null; }
|
|
39
40
|
if (!resp.ok || data?.ok === false) {
|
|
40
|
-
throw new ApiError(data?.error || `Motionode answered ${resp.status}.`, resp.status);
|
|
41
|
+
throw new ApiError(data?.error || `Motionode answered ${resp.status}.`, resp.status, data);
|
|
41
42
|
}
|
|
42
43
|
return data || {};
|
|
43
44
|
}
|
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
|
|
|
@@ -51,6 +52,24 @@ async function runJob(config, job) {
|
|
|
51
52
|
const ws = workspaceFor(config, payload.serviceUuid);
|
|
52
53
|
if (!ws) throw new Error(`No folder on this computer is linked to ${payload.serviceName || 'that repository'}. Run \`motionode link\` in it.`);
|
|
53
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
|
+
* An older job may carry no ticketDisplayId, and an older CLI may have
|
|
60
|
+
* made the folder under a different name: either way, use the worktree
|
|
61
|
+
* that already has the branch.
|
|
62
|
+
*/
|
|
63
|
+
if (ws.worktrees) {
|
|
64
|
+
const existing = await Git.worktreeOnBranch(ws.path, payload.branch);
|
|
65
|
+
const dir = existing || (payload.ticketDisplayId ? worktreePath(ws, payload.ticketDisplayId) : null);
|
|
66
|
+
if (dir) {
|
|
67
|
+
const wt = await Git.worktreeFor(ws.path, payload.branch, dir);
|
|
68
|
+
log(`checked out ${payload.branch} in ${wt.path}`);
|
|
69
|
+
const st = await Git.status(wt.path);
|
|
70
|
+
return { branch: st.branch, headSha: st.headSha, path: wt.path };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
54
73
|
const st = await Git.checkout(ws.path, payload.branch);
|
|
55
74
|
log(`checked out ${payload.branch} in ${ws.path}`);
|
|
56
75
|
return { branch: st.branch, headSha: st.headSha };
|
package/lib/commands.js
CHANGED
|
@@ -65,8 +65,26 @@ async function link(config, { serviceUuid = null, dir = process.cwd(), worktrees
|
|
|
65
65
|
if (!services.length) throw new Error('You have no projects with AI building enabled to work on locally.');
|
|
66
66
|
let pick = serviceUuid ? services.find((s) => s.uuid === serviceUuid) : null;
|
|
67
67
|
if (!pick) {
|
|
68
|
-
const
|
|
69
|
-
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
|
+
: [];
|
|
70
88
|
if (matches.length === 1) pick = matches[0];
|
|
71
89
|
else {
|
|
72
90
|
const list = matches.length ? matches : services;
|
|
@@ -96,7 +114,12 @@ async function connect(config, opts = {}) {
|
|
|
96
114
|
let relogin = !config.token || opts.relogin;
|
|
97
115
|
if (!relogin) {
|
|
98
116
|
// A computer revoked in Motionode keeps its old token here; connecting again must replace it.
|
|
99
|
-
try {
|
|
117
|
+
try {
|
|
118
|
+
const me = await client(config).get('/me');
|
|
119
|
+
// Never silently reuse a connection that may belong to someone else (a shared computer keeps one per Motionode).
|
|
120
|
+
console.log(`Already connected to ${config.apiUrl} as ${me.user?.name || me.user?.email || 'someone'}${me.user?.email ? ` <${me.user.email}>` : ''} on ${me.machine?.name || config.machine?.name || 'this computer'}.`);
|
|
121
|
+
console.log('If that is not you: run `motionode logout`, then `motionode connect` again.');
|
|
122
|
+
} catch (err) {
|
|
100
123
|
if (err.status !== 401) throw err;
|
|
101
124
|
console.log('This computer was disconnected from Motionode — connecting it again.');
|
|
102
125
|
relogin = true;
|
|
@@ -106,7 +129,7 @@ async function connect(config, opts = {}) {
|
|
|
106
129
|
if (await Git.isRepo(process.cwd())) await link(config, { serviceUuid: opts.service || null });
|
|
107
130
|
console.log('');
|
|
108
131
|
console.log('Next: keep `motionode dev` running so Motionode sees your branches and local changes,');
|
|
109
|
-
console.log('and use `motionode work <ticket>` to start a ticket (e.g. motionode work
|
|
132
|
+
console.log('and use `motionode work <ticket>` to start a ticket (e.g. motionode work T9-99).');
|
|
110
133
|
}
|
|
111
134
|
|
|
112
135
|
async function unlink(config, { dir = process.cwd() } = {}) {
|
|
@@ -117,9 +140,24 @@ async function unlink(config, { dir = process.cwd() } = {}) {
|
|
|
117
140
|
console.log(before === config.workspaces.length ? 'This folder was not linked.' : `✓ Unlinked ${root}`);
|
|
118
141
|
}
|
|
119
142
|
|
|
143
|
+
async function printWorkspaceStatus(ws, dir, label = null) {
|
|
144
|
+
try {
|
|
145
|
+
const st = await Git.status(dir);
|
|
146
|
+
const ticket = /^ai\/ticket-/.test(st.branch || '') ? ` ticket ${st.branch.replace(/^ai\/ticket-/, '')}` : '';
|
|
147
|
+
console.log(`\n${ws.projectName} / ${ws.serviceName}${label ? ` (${label})` : ''}\n ${dir}\n branch ${st.branch || '(none)'}${ticket}`);
|
|
148
|
+
console.log(` ${st.dirtyFiles.length} uncommitted file(s)${st.ahead ? `, ${st.ahead} commit(s) not pushed` : ''}${st.behind ? `, ${st.behind} behind` : ''}`);
|
|
149
|
+
const gitName = require('./doctor').gitConfig(dir, 'user.name');
|
|
150
|
+
const gitEmail = require('./doctor').gitConfig(dir, 'user.email');
|
|
151
|
+
console.log(` commits as ${gitName || '(no user.name)'}${gitEmail ? ` <${gitEmail}>` : ' (no user.email)'}`);
|
|
152
|
+
for (const f of st.dirtyFiles.slice(0, 15)) console.log(` ${f.status.padEnd(8)} ${f.path}`);
|
|
153
|
+
} catch (err) {
|
|
154
|
+
console.log(`\n${ws.projectName} / ${ws.serviceName}${label ? ` (${label})` : ''}\n ${dir}\n git: ${err.message}`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
120
158
|
async function status(config) {
|
|
121
159
|
if (!config.token) {
|
|
122
|
-
console.log(
|
|
160
|
+
console.log(`Not connected to ${config.apiUrl}. Run \`motionode connect\`${config.apiUrl === Config.PRODUCTION_API ? ' (or `motionode use <api address>` first when you work on another Motionode)' : ''}.`);
|
|
123
161
|
return;
|
|
124
162
|
}
|
|
125
163
|
console.log(`Motionode: ${config.apiUrl} — ${config.user?.name || config.user?.email || ''} on ${config.machine?.name || os.hostname()}`);
|
|
@@ -127,15 +165,18 @@ async function status(config) {
|
|
|
127
165
|
console.log('No linked folders. Run `motionode link` inside a repository.');
|
|
128
166
|
return;
|
|
129
167
|
}
|
|
168
|
+
for (const ws of config.workspaces) await printWorkspaceStatus(ws, ws.path);
|
|
169
|
+
/**
|
|
170
|
+
* Standing inside a ticket worktree: it is the same service's checkout, on
|
|
171
|
+
* the ticket's branch, and the linked folder's own line above still describes
|
|
172
|
+
* the folder. Show the checkout the person is actually working in too.
|
|
173
|
+
*/
|
|
174
|
+
const here = await Git.isRepo(process.cwd());
|
|
175
|
+
if (!here || config.workspaces.some((ws) => Config.samePath(ws.path, here))) return;
|
|
130
176
|
for (const ws of config.workspaces) {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
console.log(`\n${ws.projectName} / ${ws.serviceName}\n ${ws.path}\n branch ${st.branch || '(none)'}${ticket}`);
|
|
135
|
-
console.log(` ${st.dirtyFiles.length} uncommitted file(s)${st.ahead ? `, ${st.ahead} commit(s) not pushed` : ''}${st.behind ? `, ${st.behind} behind` : ''}`);
|
|
136
|
-
for (const f of st.dirtyFiles.slice(0, 15)) console.log(` ${f.status.padEnd(8)} ${f.path}`);
|
|
137
|
-
} catch (err) {
|
|
138
|
-
console.log(`\n${ws.projectName} / ${ws.serviceName}\n ${ws.path}\n git: ${err.message}`);
|
|
177
|
+
if (await Git.isWorktreeOf(ws.path, here)) {
|
|
178
|
+
await printWorkspaceStatus(ws, here, 'worktree');
|
|
179
|
+
return;
|
|
139
180
|
}
|
|
140
181
|
}
|
|
141
182
|
}
|
|
@@ -146,6 +187,21 @@ function worktreePath(ws, ticketRef) {
|
|
|
146
187
|
return path.join(path.dirname(ws.path), `${path.basename(ws.path)}.worktrees`, String(ticketRef).replace(/[^A-Za-z0-9._-]/g, '_'));
|
|
147
188
|
}
|
|
148
189
|
|
|
190
|
+
/**
|
|
191
|
+
* How to get a service whose repository has no linked folder here. A Motionode-hosted
|
|
192
|
+
* repository is private to Motionode, so its GitHub URL is not a clone the person can use:
|
|
193
|
+
* clone it through Motionode, against the server this command is already talking to.
|
|
194
|
+
*/
|
|
195
|
+
function cloneGuidance(b) {
|
|
196
|
+
if (b?.motionodeHosted) {
|
|
197
|
+
return b.repo
|
|
198
|
+
? `no linked folder here — clone it through Motionode: \`motionode clone ${b.repo}\`, then run \`motionode link\``
|
|
199
|
+
: 'no linked folder here — clone it through Motionode with `motionode clone <owner/repo>`, then run `motionode link`';
|
|
200
|
+
}
|
|
201
|
+
if (b?.cloneUrl) return `no linked folder here — \`git clone ${b.cloneUrl}\`, then run \`motionode link\``;
|
|
202
|
+
return 'no linked folder here — clone the repository, then run `motionode link`';
|
|
203
|
+
}
|
|
204
|
+
|
|
149
205
|
/** What `motionode finish` tells the person was recorded (the same facts the web confirmation shows). */
|
|
150
206
|
function finishLines(res) {
|
|
151
207
|
const lines = [`✓ ${res.summary || 'Build finished.'}`];
|
|
@@ -157,47 +213,128 @@ function finishLines(res) {
|
|
|
157
213
|
return lines;
|
|
158
214
|
}
|
|
159
215
|
|
|
160
|
-
|
|
161
|
-
|
|
216
|
+
/**
|
|
217
|
+
* The project of the linked folder (or ticket worktree) the person stands in, as a hint: a ticket code is only unique inside one
|
|
218
|
+
* organization, so it narrows a code shared by several of their projects. An explicit `--project` is sent separately and is strict.
|
|
219
|
+
*/
|
|
220
|
+
async function folderProject(config) {
|
|
221
|
+
const here = await Git.isRepo(process.cwd()).catch(() => null);
|
|
222
|
+
if (!here) return null;
|
|
223
|
+
let ws = Config.workspaceForPath(config, here);
|
|
224
|
+
if (!ws) {
|
|
225
|
+
for (const candidate of config.workspaces || []) {
|
|
226
|
+
if (await Git.isWorktreeOf(candidate.path, here).catch(() => false)) { ws = candidate; break; }
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return ws?.projectUuid || null;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** What to say after an answer that listed several tickets for one code. */
|
|
233
|
+
function ambiguityTip(err) {
|
|
234
|
+
return err?.data?.ambiguous ? ' Or run it from the folder of the right project, or add --project "<project name>".' : null;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** The line that says exactly which ticket (and project) a command acted on. */
|
|
238
|
+
function ticketLabel(lw, ref) {
|
|
239
|
+
const code = lw?.ticketDisplayId || ref;
|
|
240
|
+
return `${code}${lw?.ticketTitle ? ` "${lw.ticketTitle}"` : ''}${lw?.projectName ? ` in ${lw.projectName}` : ''}`;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function work(config, ref, { project = null } = {}) {
|
|
244
|
+
if (!ref) throw new Error('Which ticket? e.g. motionode work T9-99');
|
|
162
245
|
const api = client(config);
|
|
163
|
-
|
|
246
|
+
let res;
|
|
247
|
+
try {
|
|
248
|
+
res = await api.post(`/tickets/${encodeURIComponent(ref)}/work`, { project: project || undefined, projectHint: (await folderProject(config)) || undefined });
|
|
249
|
+
} catch (err) {
|
|
250
|
+
console.log(`✗ ${err.message}`);
|
|
251
|
+
if (ambiguityTip(err)) console.log(ambiguityTip(err));
|
|
252
|
+
process.exitCode = 1;
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
164
255
|
const lw = res.localWork || {};
|
|
165
|
-
|
|
256
|
+
const stepName = res.stepName || 'The step';
|
|
257
|
+
const code = lw.ticketDisplayId || ref;
|
|
258
|
+
console.log(`${ticketLabel(lw, ref)} — ${res.continued ? `${stepName} is already done; you can keep pushing fixes.` : `${stepName} is yours, working locally.`} (${config.apiUrl})`);
|
|
259
|
+
if (lw.isMine === false) {
|
|
260
|
+
// Somebody else's open session: pushes from here are refused until theirs ends.
|
|
261
|
+
console.log(`\n✗ This ticket is open locally for someone else, so pushes from you would be refused. Ask them to finish or stop first.`);
|
|
262
|
+
process.exitCode = 1;
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
let failed = 0;
|
|
266
|
+
const checkedOut = [];
|
|
267
|
+
const elsewhere = [];
|
|
166
268
|
for (const b of lw.branches || []) {
|
|
167
269
|
const ws = (config.workspaces || []).find((w) => w.serviceUuid === b.serviceUuid);
|
|
270
|
+
if (b.exists === false || (b.available === false && b.reason !== 'timeout')) {
|
|
271
|
+
// Nothing to check out: say so, never print a checkout that cannot work.
|
|
272
|
+
failed += 1;
|
|
273
|
+
console.log(` ✗ ${b.serviceName}: the branch ${b.branch || '(none)'} is not on the server${b.reason ? ` (${b.reason})` : ''}. Run \`motionode work ${code}\` again in a moment; if it keeps failing, ask your project manager.`);
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
168
276
|
if (!ws) {
|
|
169
|
-
|
|
277
|
+
// Not linked on this computer: not a failure for a person who only works on another of the ticket's repositories.
|
|
278
|
+
elsewhere.push(b.serviceName);
|
|
279
|
+
console.log(` • ${b.serviceName}: ${b.branch} (${cloneGuidance(b)})`);
|
|
170
280
|
continue;
|
|
171
281
|
}
|
|
172
282
|
try {
|
|
173
283
|
if (ws.worktrees) {
|
|
174
|
-
const dir = worktreePath(ws,
|
|
284
|
+
const dir = worktreePath(ws, code);
|
|
175
285
|
const wt = await Git.worktreeFor(ws.path, b.branch, dir);
|
|
176
|
-
|
|
286
|
+
checkedOut.push(b.serviceName);
|
|
287
|
+
console.log(` ✓ ${b.serviceName}: ${wt.created ? 'created' : 'reused'} a worktree on ${b.branch} in ${wt.path}`);
|
|
177
288
|
console.log(` Open ${wt.path} in your tool; your linked folder ${ws.path} is untouched.`);
|
|
178
289
|
} else {
|
|
179
290
|
await Git.checkout(ws.path, b.branch);
|
|
180
|
-
|
|
291
|
+
checkedOut.push(b.serviceName);
|
|
292
|
+
console.log(` ✓ ${b.serviceName}: checked out ${b.branch} in ${ws.path}`);
|
|
181
293
|
}
|
|
182
294
|
} catch (err) {
|
|
183
|
-
|
|
295
|
+
failed += 1;
|
|
296
|
+
console.log(` ✗ ${b.serviceName}: could not check out ${b.branch} — ${err.message}`);
|
|
184
297
|
if (/denied|403|401|authentication|permission/i.test(err.message)) {
|
|
185
298
|
console.log(' Your git account has no access to this repository. Ask your project manager for access.');
|
|
299
|
+
} else if (/couldn.t find remote ref/i.test(err.message)) {
|
|
300
|
+
console.log(` The ticket branch is not on the repository yet. \`motionode work ${code}\` asks Motionode to create it from the base branch: run it again in a minute. If it keeps saying this, ask your project manager (the repository may not be set up).`);
|
|
186
301
|
}
|
|
187
302
|
}
|
|
188
303
|
}
|
|
189
|
-
|
|
304
|
+
for (const problem of res.branchProblems || []) console.log(` ! ${problem}`);
|
|
305
|
+
// A stage the server could not complete (the step itself not started): never end "Ready" over it. The branch stage is already
|
|
306
|
+
// reported repository by repository above.
|
|
307
|
+
const failedStages = (res.stages || []).filter((st) => st && st.ok === false && st.key !== 'branches');
|
|
308
|
+
for (const st of failedStages) console.log(` ✗ ${st.detail || `${st.key} did not complete`}`);
|
|
309
|
+
if (failed || failedStages.length) {
|
|
310
|
+
const reasons = [];
|
|
311
|
+
if (failed) reasons.push(`${failed} repositor${failed === 1 ? 'y' : 'ies'} could not be checked out`);
|
|
312
|
+
if (failedStages.length) reasons.push(`${failedStages.length === 1 ? 'a step' : `${failedStages.length} steps`} did not start`);
|
|
313
|
+
console.log(`\n✗ Not ready: ${reasons.join(' and ')} (see above). Fix that, then run \`motionode work ${code}\` again.`);
|
|
314
|
+
// The server's own last paragraph says what to do about it (it is the one that names the problem).
|
|
315
|
+
const advice = failedStages.length ? String(res.summary || '').split('\n\n').pop().trim() : '';
|
|
316
|
+
if (advice) console.log(advice);
|
|
317
|
+
process.exitCode = 1;
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
if (!checkedOut.length) {
|
|
321
|
+
console.log(`\n✗ Nothing is checked out: none of this ticket's repositories is linked on this computer. Clone and link one (see above), then run \`motionode work ${code}\` again.`);
|
|
322
|
+
process.exitCode = 1;
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
console.log(`\n✓ Ready${elsewhere.length ? ` in ${checkedOut.join(', ')} (not on this computer: ${elsewhere.join(', ')})` : ''}. Commit with your own git and push to the ticket branch. When you are done: motionode finish ${code}`);
|
|
190
326
|
}
|
|
191
327
|
|
|
192
|
-
async function finish(config, ref, { force = false, note = null } = {}) {
|
|
193
|
-
if (!ref) throw new Error('Which ticket? e.g. motionode finish
|
|
328
|
+
async function finish(config, ref, { force = false, note = null, project = null, noChanges = false } = {}) {
|
|
329
|
+
if (!ref) throw new Error('Which ticket? e.g. motionode finish T9-99');
|
|
194
330
|
const api = client(config);
|
|
195
331
|
try {
|
|
196
|
-
console.log(`Checking the ticket branch and telling Motionode ${ref} is built
|
|
197
|
-
const res = await api.post(`/tickets/${encodeURIComponent(ref)}/finish`, { force, note });
|
|
332
|
+
console.log(`Checking the ticket branch and telling Motionode ${ref} is built… (${config.apiUrl})`);
|
|
333
|
+
const res = await api.post(`/tickets/${encodeURIComponent(ref)}/finish`, { force, note, noChanges, project: project || undefined, projectHint: (await folderProject(config)) || undefined });
|
|
198
334
|
finishLines(res).forEach((l) => console.log(l));
|
|
199
335
|
} catch (err) {
|
|
200
336
|
console.log(`✗ ${err.message}`);
|
|
337
|
+
if (ambiguityTip(err)) console.log(ambiguityTip(err));
|
|
201
338
|
if (/not on the ticket branch|not pushed|uncommitted/i.test(err.message)) {
|
|
202
339
|
console.log(' Commit and push (git commit, git push), or run with --force to finish anyway.');
|
|
203
340
|
}
|
|
@@ -210,4 +347,4 @@ async function logout(config) {
|
|
|
210
347
|
console.log('✓ Disconnected this computer. (Remove it in Motionode Settings → Development workflow to revoke it there too.)');
|
|
211
348
|
}
|
|
212
349
|
|
|
213
|
-
module.exports = { login, link, connect, unlink, status, work, finish, logout, worktreePath, finishLines };
|
|
350
|
+
module.exports = { login, link, connect, unlink, status, work, finish, logout, worktreePath, finishLines, cloneGuidance, folderProject, ticketLabel };
|
package/lib/doctor.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `motionode doctor`: where this terminal stands, in one screen, and the next command to run. Read-only: it never starts,
|
|
5
|
+
* finishes or changes anything.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const Config = require('./config');
|
|
9
|
+
const Git = require('./git');
|
|
10
|
+
const { client, version } = require('./api');
|
|
11
|
+
|
|
12
|
+
/** One git config value as the person's own git reads it in this folder, or null. */
|
|
13
|
+
function gitConfig(dir, key) {
|
|
14
|
+
try {
|
|
15
|
+
return require('child_process').execFileSync('git', ['config', key], { cwd: dir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() || null;
|
|
16
|
+
} catch (_e) {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function doctor(config) {
|
|
22
|
+
const lines = [];
|
|
23
|
+
const todo = [];
|
|
24
|
+
lines.push(`CLI ${version}`);
|
|
25
|
+
lines.push(`Motionode ${config.apiUrl}${config.apiUrl === Config.PRODUCTION_API ? ' (production, the default)' : ''}`);
|
|
26
|
+
|
|
27
|
+
let me = null;
|
|
28
|
+
if (!config.token) {
|
|
29
|
+
lines.push('Connected no');
|
|
30
|
+
todo.push('motionode connect');
|
|
31
|
+
} else {
|
|
32
|
+
try {
|
|
33
|
+
me = await client(config).get('/me');
|
|
34
|
+
lines.push(`Connected yes, as ${me.user?.name || me.user?.email || 'you'}${me.user?.email ? ` <${me.user.email}>` : ''} on ${me.machine?.name || config.machine?.name || 'this computer'}`);
|
|
35
|
+
if (me.organizations?.length) lines.push(`Organization ${me.organizations.join(', ')}`);
|
|
36
|
+
lines.push(`Projects ${(me.projects || []).map((p) => p.name).join(', ') || 'none you can work on locally'}`);
|
|
37
|
+
} catch (err) {
|
|
38
|
+
lines.push(`Connected no: ${err.message}`);
|
|
39
|
+
todo.push(err.status === 401 ? 'motionode connect --relogin' : 'motionode where (check the address; use `motionode use <api address>` to change it)');
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const root = await Git.isRepo(process.cwd()).catch(() => null);
|
|
44
|
+
if (!root) {
|
|
45
|
+
lines.push('Folder not inside a git repository');
|
|
46
|
+
todo.push('cd into your clone (or: motionode clone <owner/repo>), then motionode link');
|
|
47
|
+
} else {
|
|
48
|
+
const name = gitConfig(root, 'user.name');
|
|
49
|
+
const email = gitConfig(root, 'user.email');
|
|
50
|
+
lines.push(`Git identity ${name || '(no user.name)'}${email ? ` <${email}>` : ' (no user.email)'}`);
|
|
51
|
+
if (!name || !email) todo.push('git config user.name "Your Name" && git config user.email you@example.com');
|
|
52
|
+
const remote = await Git.remoteUrl(root);
|
|
53
|
+
lines.push(`Remote ${remote || '(no origin remote)'}`);
|
|
54
|
+
let st = null;
|
|
55
|
+
try { st = await Git.status(root); } catch (_e) { /* not readable */ }
|
|
56
|
+
lines.push(`Branch ${st?.branch || '(none)'}${st ? ` ${st.dirtyFiles.length} uncommitted, ${st.ahead || 0} to push` : ''}`);
|
|
57
|
+
let ws = Config.workspaceForPath(config, root);
|
|
58
|
+
if (!ws) {
|
|
59
|
+
for (const candidate of config.workspaces || []) {
|
|
60
|
+
if (await Git.isWorktreeOf(candidate.path, root).catch(() => false)) { ws = candidate; break; }
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
lines.push(`Linked to ${ws ? `${ws.projectName} / ${ws.serviceName}` : 'nothing (this folder is not linked)'}`);
|
|
64
|
+
if (!ws && config.token) todo.push('motionode link');
|
|
65
|
+
// A ticket branch is ai/ticket-<code>[-<ticket id>][-<slug>]; the code is what the person (and the server) know it by.
|
|
66
|
+
const code = /^ai\/ticket-(T\d+-\d+)(?:-.*)?$/.exec(st?.branch || '')?.[1] || null;
|
|
67
|
+
if (code && ws && config.token) {
|
|
68
|
+
try {
|
|
69
|
+
const res = await client(config).get(`/tickets/${encodeURIComponent(code)}?projectHint=${encodeURIComponent(ws.projectUuid || '')}`);
|
|
70
|
+
const t = res.ticket || {};
|
|
71
|
+
lines.push(`Ticket ${t.ticketDisplayId || code} "${t.ticketTitle || ''}" in ${t.projectName || ws.projectName}`);
|
|
72
|
+
// An older server does not say whose session it is: then it is only "open", never claimed as someone else's.
|
|
73
|
+
const mine = Boolean(t.active && t.isMine === true);
|
|
74
|
+
const others = Boolean(t.active && t.isMine === false);
|
|
75
|
+
lines.push(`Local work ${mine ? 'open: you can push and finish' : (others ? 'open for someone else: your pushes to this branch are refused' : (t.active ? 'open (this server does not say whose)' : 'not open: git push to this branch is refused'))}`);
|
|
76
|
+
todo.push(mine ? `motionode finish ${t.ticketDisplayId || code} (when you have pushed)` : (others ? 'ask whoever has it open to finish or stop first' : (t.active ? `motionode finish ${t.ticketDisplayId || code} (when you have pushed)` : `motionode work ${t.ticketDisplayId || code}`)));
|
|
77
|
+
} catch (err) {
|
|
78
|
+
lines.push(`Ticket branch ${st.branch}: ${err.message}`);
|
|
79
|
+
}
|
|
80
|
+
} else if (ws && config.token) {
|
|
81
|
+
todo.push('motionode work <ticket>');
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
console.log(lines.join('\n'));
|
|
85
|
+
console.log(`\nNext: ${todo.length ? `\n ${todo.join('\n ')}` : 'nothing to fix. Run motionode work <ticket> to start a ticket.'}`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
module.exports = { doctor, gitConfig };
|
package/lib/git.js
CHANGED
|
@@ -31,6 +31,24 @@ async function isRepo(dir) {
|
|
|
31
31
|
return r.ok ? r.stdout.trim() : null;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Whether `dir` is a checkout of the same repository as `linked` — either the
|
|
36
|
+
* same folder or a worktree of it. A worktree's git dir lives under the main
|
|
37
|
+
* repository's `.git/worktrees`, but it shares the common dir with the folder.
|
|
38
|
+
*/
|
|
39
|
+
async function isWorktreeOf(linked, dir) {
|
|
40
|
+
const [a, b] = await Promise.all([
|
|
41
|
+
run(linked, ['rev-parse', '--git-common-dir'], { allowFail: true }),
|
|
42
|
+
run(dir, ['rev-parse', '--git-common-dir'], { allowFail: true }),
|
|
43
|
+
]);
|
|
44
|
+
if (!a.ok || !b.ok) return false;
|
|
45
|
+
const real = (base, out) => {
|
|
46
|
+
const p = path.resolve(base, String(out || '').trim());
|
|
47
|
+
try { return fs.realpathSync(p); } catch (_e) { return p; }
|
|
48
|
+
};
|
|
49
|
+
return real(linked, a.stdout) === real(dir, b.stdout);
|
|
50
|
+
}
|
|
51
|
+
|
|
34
52
|
async function remoteUrl(dir) {
|
|
35
53
|
const r = await run(dir, ['remote', 'get-url', 'origin'], { allowFail: true });
|
|
36
54
|
return r.ok ? r.stdout.trim() : null;
|
|
@@ -112,20 +130,48 @@ async function checkout(dir, branch) {
|
|
|
112
130
|
return status(dir);
|
|
113
131
|
}
|
|
114
132
|
|
|
133
|
+
/** Where `branch` is already checked out, when a worktree of this repository has it. */
|
|
134
|
+
async function worktreeOnBranch(dir, branch) {
|
|
135
|
+
const r = await run(dir, ['worktree', 'list', '--porcelain'], { allowFail: true });
|
|
136
|
+
if (!r.ok) return null;
|
|
137
|
+
let current = null;
|
|
138
|
+
for (const raw of r.stdout.split('\n')) {
|
|
139
|
+
const line = raw.replace(/\r$/, '');
|
|
140
|
+
if (line.startsWith('worktree ')) current = line.slice('worktree '.length);
|
|
141
|
+
else if (current && line === `branch refs/heads/${branch}`) return current;
|
|
142
|
+
}
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function refreshWorktree(path, branch) {
|
|
147
|
+
await run(path, ['fetch', 'origin', branch], { allowFail: true });
|
|
148
|
+
await run(path, ['merge', '--ff-only', `origin/${branch}`], { allowFail: true });
|
|
149
|
+
return { path, created: false };
|
|
150
|
+
}
|
|
151
|
+
|
|
115
152
|
/**
|
|
116
153
|
* A separate folder (git worktree) on a ticket's branch, so two tickets of one repository never share a working tree.
|
|
117
154
|
* Reuses the folder when this ticket already has one. Returns { path, created }.
|
|
118
155
|
*/
|
|
119
156
|
async function worktreeFor(dir, branch, path) {
|
|
120
|
-
const fs = require('fs');
|
|
121
157
|
if (fs.existsSync(path)) {
|
|
122
158
|
const r = await run(path, ['rev-parse', '--abbrev-ref', 'HEAD'], { allowFail: true });
|
|
123
|
-
if (r.ok && String(r.stdout || '').trim() === branch)
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
159
|
+
if (r.ok && String(r.stdout || '').trim() === branch) return refreshWorktree(path, branch);
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* A branch is checked out in one worktree at a time, and an earlier CLI can have made the ticket's folder under a
|
|
163
|
+
* different name. Reuse the checkout that already has the branch instead of asking git to make a second one (fatal:
|
|
164
|
+
* already used by worktree). The existing folder, and anything uncommitted in it, is left as it is.
|
|
165
|
+
*/
|
|
166
|
+
const existing = await worktreeOnBranch(dir, branch);
|
|
167
|
+
if (existing) {
|
|
168
|
+
if (!fs.existsSync(existing)) {
|
|
169
|
+
throw new Error(`${branch} is registered to ${existing}, but that folder is missing. Run \`git worktree prune\` in ${dir}, then run \`motionode work <ticket>\` again.`);
|
|
127
170
|
}
|
|
128
|
-
|
|
171
|
+
return refreshWorktree(existing, branch);
|
|
172
|
+
}
|
|
173
|
+
if (fs.existsSync(path)) {
|
|
174
|
+
throw new Error(`${path} exists and is not a checkout of ${branch}. Move that folder aside, then run \`motionode work <ticket>\` again.`);
|
|
129
175
|
}
|
|
130
176
|
await run(dir, ['fetch', 'origin', branch]);
|
|
131
177
|
if (await hasLocalBranch(dir, branch)) await run(dir, ['worktree', 'add', path, branch]);
|
|
@@ -199,4 +245,4 @@ async function moveChanges(dir, branch) {
|
|
|
199
245
|
return { moved: true, branch };
|
|
200
246
|
}
|
|
201
247
|
|
|
202
|
-
module.exports = { run, isRepo, remoteUrl, normalizeRepoUrl, status, checkout, worktreeFor, diff, commit, moveChanges };
|
|
248
|
+
module.exports = { run, isRepo, isWorktreeOf, remoteUrl, normalizeRepoUrl, status, checkout, worktreeFor, worktreeOnBranch, diff, commit, moveChanges };
|
package/lib/git_credential.js
CHANGED
|
@@ -76,6 +76,24 @@ function ownerRepoOf(value) {
|
|
|
76
76
|
return m ? { owner: m[1], repo: m[2] } : null;
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
/**
|
|
80
|
+
* owner/repo behind a Motionode git-proxy URL on this Motionode
|
|
81
|
+
* (`<api>/git/<owner>/<repo>.git`), or null for any other remote. The clone
|
|
82
|
+
* `motionode clone` makes has this remote, and `motionode link` needs the
|
|
83
|
+
* repository it stands for (QA-20261023T234000Z).
|
|
84
|
+
*/
|
|
85
|
+
function proxiedRepoOf(config, remoteUrl) {
|
|
86
|
+
try {
|
|
87
|
+
const remote = new URL(String(remoteUrl || ''));
|
|
88
|
+
const api = new URL(String(config?.apiUrl || ''));
|
|
89
|
+
if (remote.host.toLowerCase() !== api.host.toLowerCase()) return null;
|
|
90
|
+
const m = remote.pathname.match(/^\/git\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/);
|
|
91
|
+
return m ? { owner: decodeURIComponent(m[1]), repo: decodeURIComponent(m[2]) } : null;
|
|
92
|
+
} catch (_e) {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
79
97
|
/** The git config that makes this CLI the credential helper for one folder (never global). */
|
|
80
98
|
function helperConfig(config) {
|
|
81
99
|
const bin = path.resolve(__dirname, '..', 'bin', 'motionode.js');
|
|
@@ -131,4 +149,4 @@ async function clone(config, target, dir = null) {
|
|
|
131
149
|
return null;
|
|
132
150
|
}
|
|
133
151
|
|
|
134
|
-
module.exports = { helper, clone, parseCredentialInput, isForThisMotionode, proxyUrl, ownerRepoOf, helperConfig };
|
|
152
|
+
module.exports = { helper, clone, parseCredentialInput, isForThisMotionode, proxyUrl, ownerRepoOf, proxiedRepoOf, helperConfig };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@motionode/cli",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.3",
|
|
4
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
5
|
"license": "UNLICENSED",
|
|
6
6
|
"bin": {
|