@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 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 T11-518 # start the ticket locally; its branch is checked out
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 T11-518 # Motionode checks your pushed commits and moves the ticket on
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> start a ticket locally and check out its branches (e.g. T11-518)
19
- motionode finish <ticket> [--force] [--note] you're done: Motionode checks your pushed commits and moves it on
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 remote = Git.normalizeRepoUrl(await Git.remoteUrl(root));
69
- const matches = remote ? services.filter((s) => Git.normalizeRepoUrl(s.repoUrl) === remote) : [];
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 { await client(config).get('/me'); } catch (err) {
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 T11-518).');
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('Not connected. Run `motionode connect`.');
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
- try {
132
- const st = await Git.status(ws.path);
133
- const ticket = /^ai\/ticket-/.test(st.branch || '') ? ` ticket ${st.branch.replace(/^ai\/ticket-/, '')}` : '';
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
- async function work(config, ref) {
161
- if (!ref) throw new Error('Which ticket? e.g. motionode work T11-518');
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
- const res = await api.post(`/tickets/${encodeURIComponent(ref)}/work`, {});
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
- console.log(`✓ ${lw.ticketDisplayId || ref} ${lw.ticketTitle || ''} — Build is yours, working locally.`);
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
- console.log(` ${b.serviceName}: ${b.branch} (no linked folder here — clone ${b.cloneUrl || 'the repo'} and run \`motionode link\`)`);
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, lw.ticketDisplayId || ref);
284
+ const dir = worktreePath(ws, code);
175
285
  const wt = await Git.worktreeFor(ws.path, b.branch, dir);
176
- console.log(` ${b.serviceName}: ${wt.created ? 'created' : 'reused'} a worktree on ${b.branch} in ${wt.path}`);
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
- console.log(` ${b.serviceName}: checked out ${b.branch} in ${ws.path}`);
291
+ checkedOut.push(b.serviceName);
292
+ console.log(` ✓ ${b.serviceName}: checked out ${b.branch} in ${ws.path}`);
181
293
  }
182
294
  } catch (err) {
183
- console.log(` ${b.serviceName}: could not check out ${b.branch} — ${err.message}`);
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
- console.log('\nCommit with your own git and push to the ticket branch. When you are done: motionode finish ' + (lw.ticketDisplayId || ref));
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 T11-518');
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
- await run(path, ['fetch', 'origin', branch], { allowFail: true });
125
- await run(path, ['merge', '--ff-only', `origin/${branch}`], { allowFail: true });
126
- return { path, created: false };
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
- throw new Error(`${path} exists and is not a checkout of ${branch}`);
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 };
@@ -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.0",
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": {