@lightward/mechanic-cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +424 -0
  3. package/bin/mechanic.js +5 -0
  4. package/dist/auth.d.ts +10 -0
  5. package/dist/auth.d.ts.map +1 -0
  6. package/dist/auth.js +104 -0
  7. package/dist/base-command.d.ts +20 -0
  8. package/dist/base-command.d.ts.map +1 -0
  9. package/dist/base-command.js +82 -0
  10. package/dist/client.d.ts +40 -0
  11. package/dist/client.d.ts.map +1 -0
  12. package/dist/client.js +172 -0
  13. package/dist/commands/auth/login.d.ts +10 -0
  14. package/dist/commands/auth/login.d.ts.map +1 -0
  15. package/dist/commands/auth/login.js +36 -0
  16. package/dist/commands/auth/logout.d.ts +6 -0
  17. package/dist/commands/auth/logout.d.ts.map +1 -0
  18. package/dist/commands/auth/logout.js +10 -0
  19. package/dist/commands/doctor.d.ts +7 -0
  20. package/dist/commands/doctor.d.ts.map +1 -0
  21. package/dist/commands/doctor.js +106 -0
  22. package/dist/commands/github/init.d.ts +10 -0
  23. package/dist/commands/github/init.d.ts.map +1 -0
  24. package/dist/commands/github/init.js +50 -0
  25. package/dist/commands/help.d.ts +7 -0
  26. package/dist/commands/help.d.ts.map +1 -0
  27. package/dist/commands/help.js +10 -0
  28. package/dist/commands/init.d.ts +13 -0
  29. package/dist/commands/init.d.ts.map +1 -0
  30. package/dist/commands/init.js +72 -0
  31. package/dist/commands/shop/status.d.ts +10 -0
  32. package/dist/commands/shop/status.d.ts.map +1 -0
  33. package/dist/commands/shop/status.js +138 -0
  34. package/dist/commands/tasks/bundle.d.ts +16 -0
  35. package/dist/commands/tasks/bundle.d.ts.map +1 -0
  36. package/dist/commands/tasks/bundle.js +83 -0
  37. package/dist/commands/tasks/diff.d.ts +16 -0
  38. package/dist/commands/tasks/diff.d.ts.map +1 -0
  39. package/dist/commands/tasks/diff.js +124 -0
  40. package/dist/commands/tasks/list.d.ts +11 -0
  41. package/dist/commands/tasks/list.d.ts.map +1 -0
  42. package/dist/commands/tasks/list.js +57 -0
  43. package/dist/commands/tasks/open.d.ts +13 -0
  44. package/dist/commands/tasks/open.d.ts.map +1 -0
  45. package/dist/commands/tasks/open.js +64 -0
  46. package/dist/commands/tasks/preview.d.ts +45 -0
  47. package/dist/commands/tasks/preview.d.ts.map +1 -0
  48. package/dist/commands/tasks/preview.js +373 -0
  49. package/dist/commands/tasks/publish.d.ts +16 -0
  50. package/dist/commands/tasks/publish.d.ts.map +1 -0
  51. package/dist/commands/tasks/publish.js +16 -0
  52. package/dist/commands/tasks/pull.d.ts +14 -0
  53. package/dist/commands/tasks/pull.d.ts.map +1 -0
  54. package/dist/commands/tasks/pull.js +96 -0
  55. package/dist/commands/tasks/push.d.ts +60 -0
  56. package/dist/commands/tasks/push.d.ts.map +1 -0
  57. package/dist/commands/tasks/push.js +370 -0
  58. package/dist/commands/tasks/status.d.ts +30 -0
  59. package/dist/commands/tasks/status.d.ts.map +1 -0
  60. package/dist/commands/tasks/status.js +183 -0
  61. package/dist/commands/tasks/unbundle.d.ts +16 -0
  62. package/dist/commands/tasks/unbundle.d.ts.map +1 -0
  63. package/dist/commands/tasks/unbundle.js +84 -0
  64. package/dist/commands/tasks/validate.d.ts +15 -0
  65. package/dist/commands/tasks/validate.d.ts.map +1 -0
  66. package/dist/commands/tasks/validate.js +78 -0
  67. package/dist/config.d.ts +15 -0
  68. package/dist/config.d.ts.map +1 -0
  69. package/dist/config.js +227 -0
  70. package/dist/errors.d.ts +10 -0
  71. package/dist/errors.d.ts.map +1 -0
  72. package/dist/errors.js +18 -0
  73. package/dist/fs.d.ts +10 -0
  74. package/dist/fs.d.ts.map +1 -0
  75. package/dist/fs.js +51 -0
  76. package/dist/github-workflows.d.ts +6 -0
  77. package/dist/github-workflows.d.ts.map +1 -0
  78. package/dist/github-workflows.js +293 -0
  79. package/dist/hash.d.ts +2 -0
  80. package/dist/hash.d.ts.map +1 -0
  81. package/dist/hash.js +5 -0
  82. package/dist/json.d.ts +4 -0
  83. package/dist/json.d.ts.map +1 -0
  84. package/dist/json.js +30 -0
  85. package/dist/tasks.d.ts +48 -0
  86. package/dist/tasks.d.ts.map +1 -0
  87. package/dist/tasks.js +546 -0
  88. package/dist/types.d.ts +144 -0
  89. package/dist/types.d.ts.map +1 -0
  90. package/dist/types.js +1 -0
  91. package/package.json +80 -0
  92. package/schemas/mechanic.schema.json +13 -0
  93. package/schemas/task-config.schema.json +23 -0
@@ -0,0 +1,370 @@
1
+ import { createHash } from "node:crypto";
2
+ import { Args, Flags } from "@oclif/core";
3
+ import { BaseCommand } from "../../base-command.js";
4
+ import { linkForSlug, saveLinks, updateLink } from "../../config.js";
5
+ import { CliError } from "../../errors.js";
6
+ import { stableStringify } from "../../json.js";
7
+ import { displayTaskPath, remoteChangedSinceLastPull, remoteChangedSinceLastPullDetails, remoteChangedSinceLastPullMessage, selectedTaskFiles, readRawTaskFile, slugFromTaskFile, taskForPush, taskSlug, unbundledHelperDirForTaskFile, validateTaskForPush, writeTaskFilePathAndRefreshHelper, } from "../../tasks.js";
8
+ function createTaskIdempotencyKey(shopDomain, slug) {
9
+ const hex = createHash("sha256")
10
+ .update(`mechanic-cli-task-create\0${shopDomain}\0${slug}`)
11
+ .digest("hex");
12
+ return [
13
+ hex.slice(0, 8),
14
+ hex.slice(8, 12),
15
+ hex.slice(12, 16),
16
+ hex.slice(16, 20),
17
+ hex.slice(20, 32),
18
+ ].join("-");
19
+ }
20
+ export default class TasksPush extends BaseCommand {
21
+ static hidden = true;
22
+ static summary = "Publish one local task, or use --all to publish every local task.";
23
+ static description = "Publish one local task to Mechanic, or use --all to publish every local task JSON file in this project.";
24
+ static args = {
25
+ file: Args.string({ required: false, description: "Task file, helper directory, linked task ID, or unique local task slug." }),
26
+ };
27
+ static flags = {
28
+ all: Flags.boolean({ description: "Publish every task JSON file in this project." }),
29
+ "dry-run": Flags.boolean({ description: "Show what would be published without writing to Mechanic or local files." }),
30
+ force: Flags.boolean({ description: "Bypass remote content hash conflict protection." }),
31
+ json: Flags.boolean({ description: "Print publish result or dry-run plan as JSON for agents, scripts, or editor integrations." }),
32
+ };
33
+ async run() {
34
+ const commandClass = this.constructor;
35
+ const { args, flags } = await this.parse(commandClass);
36
+ await this.runPush(args.file, flags);
37
+ }
38
+ async runPush(file, flags) {
39
+ const project = await this.loadProject();
40
+ const files = await selectedTaskFiles(project, file, Boolean(flags.all));
41
+ const preparation = await this.prepareTasks(project, files, Boolean(flags["dry-run"]));
42
+ if (flags["dry-run"]) {
43
+ const result = await this.dryRunPush(project, preparation, Boolean(flags.force));
44
+ if (flags.json) {
45
+ this.outputJson({
46
+ shop_domain: project.shopDomain,
47
+ dry_run: true,
48
+ blocked: result.blocked,
49
+ tasks: result.rows,
50
+ });
51
+ }
52
+ else {
53
+ this.table(this.pushRowsToTable(project, "Plan", result.rows, (status) => this.planLabel(status)));
54
+ }
55
+ if (result.blocked) {
56
+ throw new CliError("Dry-run found blocked tasks.", 2);
57
+ }
58
+ return;
59
+ }
60
+ const rows = await this.publishTasks(project, preparation, Boolean(flags.force));
61
+ if (flags.json) {
62
+ this.outputJson({
63
+ shop_domain: project.shopDomain,
64
+ dry_run: false,
65
+ tasks: rows,
66
+ });
67
+ return;
68
+ }
69
+ this.table(this.pushRowsToTable(project, "Action", rows, (status) => this.actionLabel(status)));
70
+ }
71
+ async publishTasks(project, preparation, force) {
72
+ const client = await this.verifiedClientForProject(project);
73
+ let links = project.links;
74
+ const rows = [];
75
+ await this.blockUnlinkedCreateCollisions(client, project, links, preparation.preparedTasks);
76
+ const remoteBySlug = force
77
+ ? new Map()
78
+ : await this.preflightRemoteChanges(client, project, links, preparation.preparedTasks);
79
+ for (const { file, slug, relativeFile, task } of preparation.preparedTasks) {
80
+ const link = linkForSlug({ ...project, links }, slug);
81
+ if (!link) {
82
+ const created = await client.createTask(task, createTaskIdempotencyKey(project.shopDomain, slug));
83
+ links = updateLink({ ...project, links }, slug, created.id, created.content_hash);
84
+ await saveLinks(project, links);
85
+ await writeTaskFilePathAndRefreshHelper(file, created.task);
86
+ rows.push({
87
+ file: relativeFile,
88
+ status: "created",
89
+ task_id: created.id,
90
+ content_hash: created.content_hash,
91
+ details: "created disabled; enable in Mechanic",
92
+ });
93
+ continue;
94
+ }
95
+ if (!force) {
96
+ const remote = remoteBySlug.get(slug) || await client.getTask(link.remote_id);
97
+ if (stableStringify(task) === stableStringify(taskForPush(remote.task))) {
98
+ rows.push({
99
+ file: relativeFile,
100
+ status: "skipped",
101
+ task_id: remote.id,
102
+ content_hash: remote.content_hash,
103
+ details: "",
104
+ });
105
+ continue;
106
+ }
107
+ }
108
+ const updated = await client.updateTask(link.remote_id, {
109
+ task,
110
+ ...(force ? { force: true } : { previous_content_hash: link.last_remote_content_hash }),
111
+ });
112
+ links = updateLink({ ...project, links }, slug, updated.id, updated.content_hash);
113
+ await saveLinks(project, links);
114
+ await writeTaskFilePathAndRefreshHelper(file, updated.task);
115
+ rows.push({
116
+ file: relativeFile,
117
+ status: force ? "forced" : "updated",
118
+ task_id: updated.id,
119
+ content_hash: updated.content_hash,
120
+ details: "",
121
+ });
122
+ }
123
+ return rows;
124
+ }
125
+ async preflightRemoteChanges(client, project, links, preparedTasks) {
126
+ const remoteBySlug = new Map();
127
+ const conflicts = [];
128
+ for (const { slug, relativeFile } of preparedTasks) {
129
+ const link = linkForSlug({ ...project, links }, slug);
130
+ if (!link) {
131
+ continue;
132
+ }
133
+ const remote = await client.getTask(link.remote_id);
134
+ remoteBySlug.set(slug, remote);
135
+ if (remoteChangedSinceLastPull(link, remote)) {
136
+ conflicts.push(remoteChangedSinceLastPullMessage(relativeFile, link.remote_id));
137
+ }
138
+ }
139
+ if (conflicts.length > 0) {
140
+ throw new CliError([
141
+ "Remote changes found before publishing. Nothing was published.",
142
+ "",
143
+ ...conflicts.map((conflict) => `- ${conflict}`),
144
+ ].join("\n"), 2);
145
+ }
146
+ return remoteBySlug;
147
+ }
148
+ async unlinkedCreateCollisions(client, project, links, preparedTasks) {
149
+ const unlinkedTasks = preparedTasks.filter(({ slug }) => !linkForSlug({ ...project, links }, slug));
150
+ if (unlinkedTasks.length === 0) {
151
+ return new Map();
152
+ }
153
+ const remoteBySlug = new Map();
154
+ const response = await client.listTasks();
155
+ for (const remote of response.tasks || []) {
156
+ const remoteName = typeof remote.task?.name === "string" && remote.task.name.trim()
157
+ ? remote.task.name
158
+ : remote.id;
159
+ remoteBySlug.set(taskSlug(remoteName), remote);
160
+ }
161
+ return new Map(unlinkedTasks.flatMap(({ slug, task }) => {
162
+ const localName = typeof task.name === "string" && task.name.trim() ? task.name : slug;
163
+ const localSlugs = new Set([slug, taskSlug(localName)]);
164
+ const collision = [...localSlugs].flatMap((localSlug) => {
165
+ const remote = remoteBySlug.get(localSlug);
166
+ if (!remote) {
167
+ return [];
168
+ }
169
+ const remoteName = typeof remote.task?.name === "string" && remote.task.name.trim()
170
+ ? remote.task.name
171
+ : remote.id;
172
+ return [{
173
+ remoteId: remote.id,
174
+ remoteName,
175
+ }];
176
+ })[0];
177
+ return collision ? [[slug, collision]] : [];
178
+ }));
179
+ }
180
+ async blockUnlinkedCreateCollisions(client, project, links, preparedTasks) {
181
+ const collisions = await this.unlinkedCreateCollisions(client, project, links, preparedTasks);
182
+ if (collisions.size === 0) {
183
+ return;
184
+ }
185
+ throw new CliError([
186
+ "Unlinked local task files match existing Mechanic tasks. Nothing was published.",
187
+ "",
188
+ ...preparedTasks.flatMap(({ slug, relativeFile }) => {
189
+ const collision = collisions.get(slug);
190
+ if (!collision) {
191
+ return [];
192
+ }
193
+ return [
194
+ `- ${relativeFile} looks like "${collision.remoteName}" (${collision.remoteId}). Run "mechanic tasks pull ${collision.remoteId}" before publishing, or rename the local task if it should be separate.`,
195
+ ];
196
+ }),
197
+ ].join("\n"), 2);
198
+ }
199
+ async prepareTasks(project, files, collectBlockedRows) {
200
+ const preparedTasks = [];
201
+ const rows = [];
202
+ let blocked = false;
203
+ for (const file of files) {
204
+ const slug = slugFromTaskFile(file);
205
+ const relativeFile = displayTaskPath(project, file);
206
+ let localTask;
207
+ try {
208
+ localTask = await readRawTaskFile(file);
209
+ }
210
+ catch (error) {
211
+ if (!collectBlockedRows) {
212
+ throw error;
213
+ }
214
+ blocked = true;
215
+ rows.push({
216
+ file: relativeFile,
217
+ status: "blocked",
218
+ task_id: null,
219
+ content_hash: null,
220
+ details: error instanceof Error ? error.message : String(error),
221
+ });
222
+ continue;
223
+ }
224
+ const validation = validateTaskForPush(localTask, relativeFile);
225
+ if (!validation.ok) {
226
+ const message = [
227
+ `${relativeFile} is not a valid task export.`,
228
+ ...validation.errors.map((error) => `- ${error}`),
229
+ ].join("\n");
230
+ if (!collectBlockedRows) {
231
+ throw new CliError(message, 2);
232
+ }
233
+ blocked = true;
234
+ rows.push({
235
+ file: relativeFile,
236
+ status: "blocked",
237
+ task_id: null,
238
+ content_hash: null,
239
+ details: validation.errors.join("; "),
240
+ });
241
+ continue;
242
+ }
243
+ const helperDir = await unbundledHelperDirForTaskFile(file, localTask);
244
+ if (helperDir) {
245
+ const relativeHelperDir = displayTaskPath(project, helperDir);
246
+ const message = [
247
+ `${relativeFile} is out of date with ${relativeHelperDir}.`,
248
+ "",
249
+ "The helper directory has changes that are not bundled into the JSON file.",
250
+ "",
251
+ "Bundle first:",
252
+ ` mechanic tasks bundle ${relativeHelperDir}`,
253
+ "",
254
+ "Then publish:",
255
+ ` mechanic tasks publish ${relativeFile}`,
256
+ ].join("\n");
257
+ if (!collectBlockedRows) {
258
+ throw new CliError(message, 2);
259
+ }
260
+ blocked = true;
261
+ rows.push({
262
+ file: relativeFile,
263
+ status: "blocked",
264
+ task_id: null,
265
+ content_hash: null,
266
+ details: `bundle ${relativeHelperDir} first`,
267
+ });
268
+ continue;
269
+ }
270
+ preparedTasks.push({
271
+ file,
272
+ slug,
273
+ relativeFile,
274
+ task: taskForPush(localTask),
275
+ });
276
+ }
277
+ return { blocked, preparedTasks, rows };
278
+ }
279
+ async dryRunPush(project, preparation, force) {
280
+ let client = null;
281
+ let blocked = preparation.blocked;
282
+ const getClient = async () => {
283
+ client ||= await this.verifiedClientForProject(project);
284
+ return client;
285
+ };
286
+ if (preparation.preparedTasks.length > 0) {
287
+ await getClient();
288
+ }
289
+ const createCollisions = preparation.preparedTasks.length > 0
290
+ ? await this.unlinkedCreateCollisions(await getClient(), project, project.links, preparation.preparedTasks)
291
+ : new Map();
292
+ for (const { slug, relativeFile, task } of preparation.preparedTasks) {
293
+ const link = linkForSlug(project, slug);
294
+ if (!link) {
295
+ const collision = createCollisions.get(slug);
296
+ if (collision) {
297
+ blocked = true;
298
+ preparation.rows.push({
299
+ file: relativeFile,
300
+ status: "blocked",
301
+ task_id: collision.remoteId,
302
+ content_hash: null,
303
+ details: `remote "${collision.remoteName}" already exists; pull before publishing`,
304
+ });
305
+ continue;
306
+ }
307
+ preparation.rows.push({
308
+ file: relativeFile,
309
+ status: "would create",
310
+ task_id: null,
311
+ content_hash: null,
312
+ details: "creates disabled task; enable in Mechanic",
313
+ });
314
+ continue;
315
+ }
316
+ const remote = await (await getClient()).getTask(link.remote_id);
317
+ const remoteChanged = remoteChangedSinceLastPull(link, remote);
318
+ if (remoteChanged && !force) {
319
+ blocked = true;
320
+ preparation.rows.push({
321
+ file: relativeFile,
322
+ status: "conflict",
323
+ task_id: remote.id,
324
+ content_hash: remote.content_hash,
325
+ details: remoteChangedSinceLastPullDetails(),
326
+ });
327
+ continue;
328
+ }
329
+ const localComparable = stableStringify(task);
330
+ const remoteComparable = stableStringify(taskForPush(remote.task));
331
+ const changed = localComparable !== remoteComparable;
332
+ const details = remoteChanged ? remoteChangedSinceLastPullDetails(true) : "";
333
+ preparation.rows.push({
334
+ file: relativeFile,
335
+ status: changed ? "would update" : "no change",
336
+ task_id: remote.id,
337
+ content_hash: remote.content_hash,
338
+ details,
339
+ });
340
+ }
341
+ return { blocked, rows: preparation.rows };
342
+ }
343
+ pushRowsToTable(project, statusHeader, rows, label) {
344
+ return [
345
+ ["File", statusHeader, "Task ID", "Hash", "Details"],
346
+ ...rows.map((row) => [
347
+ this.taskName(row.file),
348
+ label(row.status),
349
+ row.task_id ? this.taskId(project, row.task_id) : "--",
350
+ row.content_hash ? this.muted(row.content_hash) : "--",
351
+ row.details,
352
+ ]),
353
+ ];
354
+ }
355
+ planLabel(plan) {
356
+ switch (plan) {
357
+ case "would create":
358
+ return this.success(plan);
359
+ case "would update":
360
+ return this.color("cyan", plan);
361
+ case "no change":
362
+ return this.muted(plan);
363
+ case "conflict":
364
+ case "blocked":
365
+ return this.color("red", plan);
366
+ default:
367
+ return plan;
368
+ }
369
+ }
370
+ }
@@ -0,0 +1,30 @@
1
+ import { BaseCommand } from "../../base-command.js";
2
+ import { linkForSlug } from "../../config.js";
3
+ import type { MechanicClient } from "../../client.js";
4
+ import type { JsonObject, Project } from "../../types.js";
5
+ type HelperStatus = {
6
+ label: string;
7
+ blocked: boolean;
8
+ details?: string;
9
+ };
10
+ export default class TasksStatus extends BaseCommand {
11
+ static summary: string;
12
+ static description: string;
13
+ static args: {
14
+ file: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
15
+ };
16
+ static flags: {
17
+ json: import("@oclif/core/interfaces").BooleanFlag<boolean>;
18
+ remote: import("@oclif/core/interfaces").BooleanFlag<boolean>;
19
+ };
20
+ run(): Promise<void>;
21
+ helperStatus(project: Project, file: string, task: JsonObject): Promise<HelperStatus>;
22
+ remoteStatus(client: MechanicClient | null, task: JsonObject, link: ReturnType<typeof linkForSlug>): Promise<{
23
+ label: string;
24
+ details?: string;
25
+ }>;
26
+ localLabel(label: string): string;
27
+ remoteLabel(label: string): string;
28
+ }
29
+ export {};
30
+ //# sourceMappingURL=status.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"status.d.ts","sourceRoot":"","sources":["../../../src/commands/tasks/status.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAC;AACpD,OAAO,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAC;AAe9C,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AACtD,OAAO,KAAK,EAAE,UAAU,EAAE,OAAO,EAAE,MAAM,gBAAgB,CAAC;AAE1D,KAAK,YAAY,GAAG;IAClB,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,OAAO,CAAC;IACjB,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB,CAAC;AAqBF,MAAM,CAAC,OAAO,OAAO,WAAY,SAAQ,WAAW;IAClD,OAAgB,OAAO,SAAuE;IAC9F,OAAgB,WAAW,SAA0H;IAErJ,OAAgB,IAAI;;MAElB;IAEF,OAAgB,KAAK;;;MAQnB;IAEI,GAAG,IAAI,OAAO,CAAC,IAAI,CAAC;IAsGpB,YAAY,CAAC,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,UAAU,GAAG,OAAO,CAAC,YAAY,CAAC;IA4BrF,YAAY,CAChB,MAAM,EAAE,cAAc,GAAG,IAAI,EAC7B,IAAI,EAAE,UAAU,EAChB,IAAI,EAAE,UAAU,CAAC,OAAO,WAAW,CAAC,GACnC,OAAO,CAAC;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,OAAO,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IAsB/C,UAAU,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM;IAejC,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM;CAgBnC"}
@@ -0,0 +1,183 @@
1
+ import { Args, Flags } from "@oclif/core";
2
+ import path from "node:path";
3
+ import { BaseCommand } from "../../base-command.js";
4
+ import { linkForSlug } from "../../config.js";
5
+ import { pathExists } from "../../fs.js";
6
+ import { stableStringify } from "../../json.js";
7
+ import { displayTaskPath, helperDirForTaskFile, readRawTaskFile, remoteChangedSinceLastPull, remoteChangedSinceLastPullDetails, selectedTaskFiles, slugFromTaskFile, taskForPush, unbundledHelperDirForTaskFile, validateTaskForPush, } from "../../tasks.js";
8
+ function errorMessage(error) {
9
+ return error instanceof Error ? error.message : String(error);
10
+ }
11
+ export default class TasksStatus extends BaseCommand {
12
+ static summary = "Show local task link, readiness, and optional remote sync status.";
13
+ static description = "Show whether local task files are linked, ready to publish, and optionally in sync with their remote Mechanic tasks.";
14
+ static args = {
15
+ file: Args.string({ required: false, description: "Task file, helper directory, linked task ID, or unique local task slug. Defaults to every task JSON file." }),
16
+ };
17
+ static flags = {
18
+ json: Flags.boolean({
19
+ description: "Print task status as JSON for agents, scripts, or editor integrations.",
20
+ }),
21
+ remote: Flags.boolean({
22
+ char: "r",
23
+ description: "Check linked remote tasks and report no-change, local-change, or conflict state.",
24
+ }),
25
+ };
26
+ async run() {
27
+ const { args, flags } = await this.parse(TasksStatus);
28
+ const project = await this.loadProject();
29
+ const files = await selectedTaskFiles(project, args.file, !args.file);
30
+ const statuses = [];
31
+ let client = null;
32
+ const getClient = async () => {
33
+ client ||= await this.verifiedClientForProject(project);
34
+ return client;
35
+ };
36
+ for (const file of files) {
37
+ const slug = slugFromTaskFile(file);
38
+ const relativeFile = displayTaskPath(project, file);
39
+ const link = linkForSlug(project, slug);
40
+ let task = null;
41
+ const details = [];
42
+ let helperStatus = { label: "not checked", blocked: true };
43
+ try {
44
+ task = await readRawTaskFile(file);
45
+ const validation = validateTaskForPush(task, relativeFile);
46
+ if (!validation.ok) {
47
+ helperStatus = {
48
+ label: "blocked",
49
+ blocked: true,
50
+ details: validation.errors.join("; "),
51
+ };
52
+ }
53
+ else {
54
+ helperStatus = await this.helperStatus(project, file, task);
55
+ }
56
+ }
57
+ catch (error) {
58
+ helperStatus = {
59
+ label: "blocked",
60
+ blocked: true,
61
+ details: errorMessage(error),
62
+ };
63
+ }
64
+ if (helperStatus.details) {
65
+ details.push(helperStatus.details);
66
+ }
67
+ const remote = flags.remote && task && !helperStatus.blocked
68
+ ? await this.remoteStatus(link ? await getClient() : null, task, link)
69
+ : flags.remote ? { label: "skipped", details: undefined } : null;
70
+ if (remote?.details) {
71
+ details.push(remote.details);
72
+ }
73
+ else if (!link && task && !helperStatus.blocked) {
74
+ details.push("publish will create disabled task");
75
+ }
76
+ statuses.push({
77
+ file: relativeFile,
78
+ slug,
79
+ link: {
80
+ linked: Boolean(link),
81
+ remote_id: link?.remote_id || null,
82
+ },
83
+ local: helperStatus,
84
+ remote,
85
+ details,
86
+ });
87
+ }
88
+ if (flags.json) {
89
+ this.outputJson({
90
+ shop_domain: project.shopDomain,
91
+ remote_checked: Boolean(flags.remote),
92
+ tasks: statuses,
93
+ });
94
+ return;
95
+ }
96
+ const rows = flags.remote
97
+ ? [["File", "Link", "Local", "Remote", "Task ID", "Details"]]
98
+ : [["File", "Link", "Local", "Task ID", "Details"]];
99
+ for (const status of statuses) {
100
+ const row = [
101
+ this.taskName(status.file),
102
+ status.link.linked ? this.success("linked") : this.color("yellow", "unlinked"),
103
+ this.localLabel(status.local.label),
104
+ ];
105
+ if (flags.remote) {
106
+ row.push(this.remoteLabel(status.remote?.label || "not checked"));
107
+ }
108
+ row.push(status.link.remote_id ? this.taskId(project, status.link.remote_id) : "--", status.details.join("; "));
109
+ rows.push(row);
110
+ }
111
+ this.table(rows);
112
+ }
113
+ async helperStatus(project, file, task) {
114
+ const helperDir = helperDirForTaskFile(file);
115
+ if (!helperDir || !(await pathExists(path.join(helperDir, "task.json")))) {
116
+ return { label: "ready", blocked: false };
117
+ }
118
+ try {
119
+ const driftDir = await unbundledHelperDirForTaskFile(file, task);
120
+ if (driftDir) {
121
+ return {
122
+ label: "needs bundle",
123
+ blocked: true,
124
+ details: `run mechanic tasks bundle ${displayTaskPath(project, driftDir)}`,
125
+ };
126
+ }
127
+ }
128
+ catch (error) {
129
+ return {
130
+ label: "blocked",
131
+ blocked: true,
132
+ details: errorMessage(error),
133
+ };
134
+ }
135
+ return { label: "ready", blocked: false };
136
+ }
137
+ async remoteStatus(client, task, link) {
138
+ if (!link) {
139
+ return { label: "will create", details: "publish will create disabled task" };
140
+ }
141
+ if (!client) {
142
+ return { label: "not checked" };
143
+ }
144
+ const remote = await client.getTask(link.remote_id);
145
+ if (remoteChangedSinceLastPull(link, remote)) {
146
+ return { label: "conflict", details: remoteChangedSinceLastPullDetails() };
147
+ }
148
+ if (stableStringify(taskForPush(task)) !== stableStringify(taskForPush(remote.task))) {
149
+ return { label: "local changes", details: "publish would update" };
150
+ }
151
+ return { label: "no change" };
152
+ }
153
+ localLabel(label) {
154
+ switch (label) {
155
+ case "ready":
156
+ return this.success(label);
157
+ case "needs bundle":
158
+ return this.color("yellow", label);
159
+ case "blocked":
160
+ return this.color("red", label);
161
+ case "not checked":
162
+ return this.muted(label);
163
+ default:
164
+ return label;
165
+ }
166
+ }
167
+ remoteLabel(label) {
168
+ switch (label) {
169
+ case "will create":
170
+ case "no change":
171
+ return this.success(label);
172
+ case "local changes":
173
+ return this.color("cyan", label);
174
+ case "conflict":
175
+ return this.color("red", label);
176
+ case "skipped":
177
+ case "not checked":
178
+ return this.muted(label);
179
+ default:
180
+ return label;
181
+ }
182
+ }
183
+ }
@@ -0,0 +1,16 @@
1
+ import { BaseCommand } from "../../base-command.js";
2
+ export default class TasksUnbundle extends BaseCommand {
3
+ static summary: string;
4
+ static description: string;
5
+ static examples: string[];
6
+ static args: {
7
+ file: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
8
+ };
9
+ static flags: {
10
+ json: import("@oclif/core/interfaces").BooleanFlag<boolean>;
11
+ out: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
12
+ };
13
+ run(): Promise<void>;
14
+ private resolveUnbundlePaths;
15
+ }
16
+ //# sourceMappingURL=unbundle.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"unbundle.d.ts","sourceRoot":"","sources":["../../../src/commands/tasks/unbundle.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAC;AAKpD,MAAM,CAAC,OAAO,OAAO,aAAc,SAAQ,WAAW;IACpD,OAAgB,OAAO,SAAkD;IACzE,OAAgB,WAAW,SAAiJ;IAC5K,OAAgB,QAAQ,WAKtB;IAEF,OAAgB,IAAI;;MAElB;IAEF,OAAgB,KAAK;;;MAOnB;IAEI,GAAG,IAAI,OAAO,CAAC,IAAI,CAAC;YA+BZ,oBAAoB;CA2CnC"}