@motionode/cli 0.2.1 → 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 +14 -4
- package/lib/api.js +3 -2
- package/lib/bridge.js +12 -6
- package/lib/commands.js +130 -26
- package/lib/doctor.js +88 -0
- package/lib/git.js +53 -7
- 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,11 @@ 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
|
+
}
|
|
67
75
|
/**
|
|
68
76
|
* A server named for `connect` is the one this computer keeps using: the work
|
|
69
77
|
* panel prints `motionode connect --api …` once, and every command after it
|
|
@@ -90,9 +98,9 @@ async function main() {
|
|
|
90
98
|
case 'dev':
|
|
91
99
|
return Bridge.run(config);
|
|
92
100
|
case 'work':
|
|
93
|
-
return Commands.work(config, args._[0]);
|
|
101
|
+
return Commands.work(config, args._[0], { project: typeof args.project === 'string' ? args.project : null });
|
|
94
102
|
case 'finish':
|
|
95
|
-
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 });
|
|
96
104
|
case 'status':
|
|
97
105
|
return Commands.status(config);
|
|
98
106
|
case 'use': {
|
|
@@ -101,6 +109,8 @@ async function main() {
|
|
|
101
109
|
console.log(`✓ Using ${api}${next.token ? '' : ' — run "motionode connect" in a repository folder to connect this computer to it'}`);
|
|
102
110
|
return null;
|
|
103
111
|
}
|
|
112
|
+
case 'doctor':
|
|
113
|
+
return require('../lib/doctor').doctor(config);
|
|
104
114
|
case 'where':
|
|
105
115
|
console.log(`${config.apiUrl}${config.token ? ` — connected as ${config.user?.name || config.user?.email || 'you'} (${config.machine?.name || 'this computer'})` : ' — not connected'}`);
|
|
106
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
|
@@ -56,13 +56,19 @@ async function runJob(config, job) {
|
|
|
56
56
|
* In --worktrees mode `motionode work` owns the ticket's branch in its own
|
|
57
57
|
* folder, so checking it out in the linked folder conflicts with that
|
|
58
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.
|
|
59
62
|
*/
|
|
60
|
-
if (ws.worktrees
|
|
61
|
-
const
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
+
}
|
|
66
72
|
}
|
|
67
73
|
const st = await Git.checkout(ws.path, payload.branch);
|
|
68
74
|
log(`checked out ${payload.branch} in ${ws.path}`);
|
package/lib/commands.js
CHANGED
|
@@ -114,7 +114,12 @@ async function connect(config, opts = {}) {
|
|
|
114
114
|
let relogin = !config.token || opts.relogin;
|
|
115
115
|
if (!relogin) {
|
|
116
116
|
// A computer revoked in Motionode keeps its old token here; connecting again must replace it.
|
|
117
|
-
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) {
|
|
118
123
|
if (err.status !== 401) throw err;
|
|
119
124
|
console.log('This computer was disconnected from Motionode — connecting it again.');
|
|
120
125
|
relogin = true;
|
|
@@ -124,7 +129,7 @@ async function connect(config, opts = {}) {
|
|
|
124
129
|
if (await Git.isRepo(process.cwd())) await link(config, { serviceUuid: opts.service || null });
|
|
125
130
|
console.log('');
|
|
126
131
|
console.log('Next: keep `motionode dev` running so Motionode sees your branches and local changes,');
|
|
127
|
-
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).');
|
|
128
133
|
}
|
|
129
134
|
|
|
130
135
|
async function unlink(config, { dir = process.cwd() } = {}) {
|
|
@@ -135,9 +140,24 @@ async function unlink(config, { dir = process.cwd() } = {}) {
|
|
|
135
140
|
console.log(before === config.workspaces.length ? 'This folder was not linked.' : `✓ Unlinked ${root}`);
|
|
136
141
|
}
|
|
137
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
|
+
|
|
138
158
|
async function status(config) {
|
|
139
159
|
if (!config.token) {
|
|
140
|
-
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)' : ''}.`);
|
|
141
161
|
return;
|
|
142
162
|
}
|
|
143
163
|
console.log(`Motionode: ${config.apiUrl} — ${config.user?.name || config.user?.email || ''} on ${config.machine?.name || os.hostname()}`);
|
|
@@ -145,15 +165,18 @@ async function status(config) {
|
|
|
145
165
|
console.log('No linked folders. Run `motionode link` inside a repository.');
|
|
146
166
|
return;
|
|
147
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;
|
|
148
176
|
for (const ws of config.workspaces) {
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
console.log(`\n${ws.projectName} / ${ws.serviceName}\n ${ws.path}\n branch ${st.branch || '(none)'}${ticket}`);
|
|
153
|
-
console.log(` ${st.dirtyFiles.length} uncommitted file(s)${st.ahead ? `, ${st.ahead} commit(s) not pushed` : ''}${st.behind ? `, ${st.behind} behind` : ''}`);
|
|
154
|
-
for (const f of st.dirtyFiles.slice(0, 15)) console.log(` ${f.status.padEnd(8)} ${f.path}`);
|
|
155
|
-
} catch (err) {
|
|
156
|
-
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;
|
|
157
180
|
}
|
|
158
181
|
}
|
|
159
182
|
}
|
|
@@ -190,47 +213,128 @@ function finishLines(res) {
|
|
|
190
213
|
return lines;
|
|
191
214
|
}
|
|
192
215
|
|
|
193
|
-
|
|
194
|
-
|
|
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');
|
|
195
245
|
const api = client(config);
|
|
196
|
-
|
|
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
|
+
}
|
|
197
255
|
const lw = res.localWork || {};
|
|
198
|
-
|
|
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 = [];
|
|
199
268
|
for (const b of lw.branches || []) {
|
|
200
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
|
+
}
|
|
201
276
|
if (!ws) {
|
|
202
|
-
|
|
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)})`);
|
|
203
280
|
continue;
|
|
204
281
|
}
|
|
205
282
|
try {
|
|
206
283
|
if (ws.worktrees) {
|
|
207
|
-
const dir = worktreePath(ws,
|
|
284
|
+
const dir = worktreePath(ws, code);
|
|
208
285
|
const wt = await Git.worktreeFor(ws.path, b.branch, dir);
|
|
209
|
-
|
|
286
|
+
checkedOut.push(b.serviceName);
|
|
287
|
+
console.log(` ✓ ${b.serviceName}: ${wt.created ? 'created' : 'reused'} a worktree on ${b.branch} in ${wt.path}`);
|
|
210
288
|
console.log(` Open ${wt.path} in your tool; your linked folder ${ws.path} is untouched.`);
|
|
211
289
|
} else {
|
|
212
290
|
await Git.checkout(ws.path, b.branch);
|
|
213
|
-
|
|
291
|
+
checkedOut.push(b.serviceName);
|
|
292
|
+
console.log(` ✓ ${b.serviceName}: checked out ${b.branch} in ${ws.path}`);
|
|
214
293
|
}
|
|
215
294
|
} catch (err) {
|
|
216
|
-
|
|
295
|
+
failed += 1;
|
|
296
|
+
console.log(` ✗ ${b.serviceName}: could not check out ${b.branch} — ${err.message}`);
|
|
217
297
|
if (/denied|403|401|authentication|permission/i.test(err.message)) {
|
|
218
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).`);
|
|
219
301
|
}
|
|
220
302
|
}
|
|
221
303
|
}
|
|
222
|
-
|
|
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}`);
|
|
223
326
|
}
|
|
224
327
|
|
|
225
|
-
async function finish(config, ref, { force = false, note = null } = {}) {
|
|
226
|
-
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');
|
|
227
330
|
const api = client(config);
|
|
228
331
|
try {
|
|
229
|
-
console.log(`Checking the ticket branch and telling Motionode ${ref} is built
|
|
230
|
-
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 });
|
|
231
334
|
finishLines(res).forEach((l) => console.log(l));
|
|
232
335
|
} catch (err) {
|
|
233
336
|
console.log(`✗ ${err.message}`);
|
|
337
|
+
if (ambiguityTip(err)) console.log(ambiguityTip(err));
|
|
234
338
|
if (/not on the ticket branch|not pushed|uncommitted/i.test(err.message)) {
|
|
235
339
|
console.log(' Commit and push (git commit, git push), or run with --force to finish anyway.');
|
|
236
340
|
}
|
|
@@ -243,4 +347,4 @@ async function logout(config) {
|
|
|
243
347
|
console.log('✓ Disconnected this computer. (Remove it in Motionode Settings → Development workflow to revoke it there too.)');
|
|
244
348
|
}
|
|
245
349
|
|
|
246
|
-
module.exports = { login, link, connect, unlink, status, work, finish, logout, worktreePath, finishLines, cloneGuidance };
|
|
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/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": {
|