@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
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Lightward
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,424 @@
1
+ # Mechanic CLI
2
+
3
+ Develop Mechanic tasks locally: pull Shopify automation tasks from Mechanic,
4
+ edit Liquid and docs in normal files, preview changes safely, review diffs, and
5
+ publish intentionally.
6
+
7
+ Mechanic is a Shopify automation platform for teams that need flexible,
8
+ code-backed workflows. Install Mechanic from the Shopify App Store at
9
+ <https://apps.shopify.com/mechanic>, read the docs at
10
+ <https://learn.mechanic.dev/>, or start with the CLI guide at
11
+ <https://learn.mechanic.dev/platform/mechanic-cli>.
12
+
13
+ This CLI is built around the same task export shape used by Mechanic, so tasks
14
+ move cleanly between the app, local files, version control, and pull requests.
15
+
16
+ Use it to:
17
+
18
+ - pull tasks from Mechanic into a local repository
19
+ - edit Liquid, Markdown, and task configuration in normal files
20
+ - preview task changes before saving them to Mechanic
21
+ - diff local files against the current Mechanic task
22
+ - publish one task intentionally, with dry-run and conflict protection
23
+ - automate single-shop task updates with optional GitHub Actions workflows
24
+
25
+ Mechanic is made by Lightward. You can also meet Lightward AI at
26
+ <https://lightward.com>.
27
+
28
+ ## Requirements
29
+
30
+ - Node 22 or newer
31
+ - A Mechanic shop with an API token created in the Mechanic app
32
+
33
+ ## Install
34
+
35
+ Install from npm:
36
+
37
+ ```bash
38
+ npm install -g @lightward/mechanic-cli
39
+ ```
40
+
41
+ ## Quick Start
42
+
43
+ Create an API token in Mechanic:
44
+
45
+ 1. Open the shop in Mechanic.
46
+ 2. Go to Settings -> API tokens.
47
+ 3. Create a token named for the place it will be used, like `Laptop` or `GitHub Actions`.
48
+ 4. Copy the token immediately. Mechanic only shows it once.
49
+
50
+ Initialize a local project for that Shopify shop, then paste the token when
51
+ prompted:
52
+
53
+ ```bash
54
+ mechanic init --shop example.myshopify.com
55
+ ```
56
+
57
+ Pull existing Mechanic tasks into local JSON files:
58
+
59
+ ```bash
60
+ mechanic tasks pull
61
+ ```
62
+
63
+ For most Liquid or documentation edits, unbundle one task into helper files,
64
+ edit those files, then bundle the helper directory back into its canonical JSON
65
+ file. The JSON file remains the deployable source of truth; the helper directory
66
+ is the comfortable editing view beside it.
67
+
68
+ ```bash
69
+ mechanic tasks unbundle tasks/order-tagger.json
70
+ # edit tasks/order-tagger/script.liquid, docs.md, or task.json
71
+ mechanic tasks bundle tasks/order-tagger.json
72
+ mechanic tasks status
73
+ mechanic tasks preview tasks/order-tagger.json
74
+ mechanic tasks diff tasks/order-tagger.json
75
+ mechanic tasks publish tasks/order-tagger.json --dry-run
76
+ mechanic tasks publish tasks/order-tagger.json
77
+ ```
78
+
79
+ For normal setup, paste the token into the masked prompt or run
80
+ `mechanic auth login` after `mechanic init`. In CI, store the token as
81
+ `MECHANIC_API_TOKEN`. The `--token` flag exists for controlled automation, but
82
+ avoid typing secrets into shared shells because shell history or process listings
83
+ can expose them.
84
+
85
+ To check whether the shop is currently busy or backlogged:
86
+
87
+ ```bash
88
+ mechanic shop status
89
+ ```
90
+
91
+ Most commands that editor integrations or agents would call support `--json`.
92
+ Use JSON output for automation instead of parsing tables or colored text.
93
+
94
+ ## Commands
95
+
96
+ ```bash
97
+ mechanic init --shop example.myshopify.com [--token <token>] [--api-base-url <url>] [--app-url <url>] [--force]
98
+ mechanic help [command]
99
+ mechanic doctor
100
+ mechanic auth login [--token <token>]
101
+ mechanic auth logout
102
+ mechanic github init [--force]
103
+ mechanic shop status [--json]
104
+ mechanic tasks list [--verbose] [--json]
105
+ mechanic tasks open <task>
106
+ mechanic tasks status [task] [--remote] [--json]
107
+ mechanic tasks pull [--force]
108
+ mechanic tasks pull <task> [--force]
109
+ mechanic tasks pull --all [--force]
110
+ mechanic tasks preview <task> [--remote] [--verbose] [--json]
111
+ mechanic tasks diff <task> [--exit-code] [--json]
112
+ mechanic tasks diff --all [--exit-code] [--json]
113
+ mechanic tasks publish <task> [--force] [--dry-run] [--json]
114
+ mechanic tasks publish --all [--force] [--dry-run] [--json]
115
+ mechanic tasks unbundle <task> [--out <dir>] [--json]
116
+ mechanic tasks bundle <dir|file> [--out <file>] [--json]
117
+ mechanic tasks validate <file|dir> [--json]
118
+ ```
119
+
120
+ Most task commands accept a `<task>` selector. A task selector can be a local
121
+ JSON file, a helper directory, a linked remote task ID, or a unique local task
122
+ slug:
123
+
124
+ ```bash
125
+ mechanic tasks preview tasks/order-tagger.json
126
+ mechanic tasks preview tasks/order-tagger
127
+ mechanic tasks preview order-tagger
128
+ mechanic tasks preview 171578bf-79e2-46af-857a-dbd71c6b7b2b
129
+ ```
130
+
131
+ The local file is the working copy. The remote task ID is the Mechanic app's
132
+ address for that task. `tasks list` shows linked local files when the CLI knows
133
+ them. If a short selector matches more than one local file, the CLI asks for the
134
+ full file path.
135
+
136
+ `tasks pull` pulls every task when you run it without arguments. Pass a task
137
+ selector when you only want one task. `tasks diff` and `tasks publish` operate
138
+ on one task when you pass a selector. They operate on every task only when you
139
+ explicitly pass `--all`.
140
+
141
+ `shop status` shows the current Mechanic run queue for the configured shop:
142
+ running runs, waiting runs, queue lag, and the largest backlog groups by task,
143
+ action, and event topic.
144
+
145
+ `tasks status` shows whether local task JSON files are linked to remote Mechanic
146
+ tasks and ready to publish. It is local-only by default; add `--remote` to check
147
+ whether linked remote tasks are unchanged, locally changed, or conflicted.
148
+ Add `--json` when an editor integration needs task readiness, link state, and
149
+ remote sync state without parsing table output.
150
+
151
+ `tasks diff` compares the current remote task to your local file, groups output
152
+ by changed field, and only shows nearby changed lines. Differences are
153
+ informational by default; add `--exit-code` when a script or CI job should treat
154
+ differences as a nonzero result.
155
+
156
+ `tasks preview` is the confidence check before publishing. It sends one local
157
+ task to Mechanic's preview engine without saving it, then reports whether the
158
+ sample events passed, which actions would run, validation errors, and Shopify
159
+ permissions detected by the previewed paths. Add `--remote` to preview the
160
+ current task already in Mechanic instead of your local draft. Add `--verbose`
161
+ to show event, task run, and action run result details in the terminal. Add
162
+ `--json` when an agent, script, or CI job needs the raw preview response.
163
+ Missing Shopify permissions are approved in the Mechanic app after publishing
164
+ or enabling the task.
165
+
166
+ `tasks publish` sends local task JSON back to Mechanic.
167
+
168
+ `tasks publish --dry-run` validates the selected files, checks for unbundled
169
+ helper changes, fetches linked remote tasks, checks unlinked files against
170
+ existing remote task names, and prints what would create, update, stay
171
+ unchanged, or conflict. It does not write to Mechanic, `.mechanic/links.json`,
172
+ or local task JSON. New tasks created by `tasks publish` are created disabled;
173
+ review and enable them in Mechanic when they are ready to run.
174
+
175
+ For automation or editor integrations, add `--json` to `tasks list`, `tasks
176
+ status`, `tasks diff`, `tasks validate`, `tasks bundle`, `tasks unbundle`,
177
+ `tasks publish`, or `shop status`.
178
+
179
+ `tasks unbundle` and `tasks bundle` operate on one task at a time. By default,
180
+ `mechanic tasks unbundle tasks/order-tagger.json` writes to
181
+ `tasks/order-tagger/`, and `mechanic tasks bundle tasks/order-tagger.json`
182
+ writes back to `tasks/order-tagger.json`.
183
+
184
+ When a task has `subscriptions_template`, the editable helper file is
185
+ `subscriptions.liquid`. Mechanic renders that template into `subscriptions`, so
186
+ the CLI treats `subscriptions` as generated state instead of something to edit
187
+ or publish by hand.
188
+
189
+ `mechanic init` accepts `--token`, reads `MECHANIC_API_TOKEN`, or prompts for an
190
+ API token in an interactive terminal. It verifies the token and stores it
191
+ outside the project. `mechanic auth login` does the same thing later if you skip
192
+ token setup during init. For automation, set `MECHANIC_API_TOKEN`; it takes
193
+ precedence over any locally stored token for authenticated commands.
194
+
195
+ `mechanic doctor` checks the local project config, token source, verified shop,
196
+ and task API access. Run it after `mechanic auth login` or when a project stops
197
+ syncing cleanly.
198
+
199
+ `mechanic init` defaults to `https://api.mechanic.dev` for the API and refuses
200
+ to overwrite an existing project unless you pass `--force`. The CLI only sends
201
+ API tokens to trusted Mechanic API hosts by default. Use `--api-base-url` or
202
+ `MECHANIC_API_BASE_URL` only for a staging or dedicated Mechanic API host you
203
+ control; set `MECHANIC_TRUST_API_BASE_URL=1` only when you intentionally trust
204
+ that host. Task IDs in command output link back to the Mechanic embedded app in
205
+ terminals that support hyperlinks; use `mechanic tasks open` when you want to
206
+ open a task explicitly. Use `--app-url` or `MECHANIC_APP_URL` if your shop uses
207
+ a non-production Shopify app alias.
208
+
209
+ Use `mechanic tasks open <task>` to open a task in the Mechanic app from its
210
+ remote ID, linked local task JSON file, helper directory, or unique local task
211
+ slug.
212
+
213
+ ## Advanced: GitHub Actions Task Workflows
214
+
215
+ Local CLI usage works without GitHub Actions. If you want optional single-shop
216
+ Git sync automation, start from a populated Mechanic CLI project, then generate
217
+ the recommended workflows:
218
+
219
+ ```bash
220
+ mechanic tasks pull
221
+ mechanic github init
222
+ ```
223
+
224
+ This creates:
225
+
226
+ ```text
227
+ .github/workflows/mechanic-validate.yml
228
+ .github/workflows/mechanic-deploy.yml
229
+ .github/workflows/mechanic-sync-from-app.yml
230
+ ```
231
+
232
+ Re-run with `--force` only when you intentionally want to replace those
233
+ workflow files.
234
+
235
+ Add one repository or organization secret named `MECHANIC_API_TOKEN` containing
236
+ a Mechanic API token for the shop in `mechanic.json`.
237
+
238
+ From the task repository, the safest repository-secret setup is:
239
+
240
+ ```bash
241
+ gh secret set MECHANIC_API_TOKEN
242
+ ```
243
+
244
+ Paste the token when prompted. If you are not running the command from the task
245
+ repository checkout, add `--repo OWNER/REPO`.
246
+
247
+ The generated workflows install the same `@lightward/mechanic-cli` version that
248
+ generated them and use the `api_base_url` committed in `mechanic.json`.
249
+
250
+ Only enable the deploy and sync workflows in repos whose maintainers you trust.
251
+ Those workflows use the Mechanic API token, and the sync workflow also needs
252
+ repository write permissions so it can open or update sync-back pull requests.
253
+
254
+ The generated workflows do three jobs:
255
+
256
+ - `mechanic-validate.yml` runs on pull requests and manually, with no Mechanic
257
+ token, validates `tasks/`, and fails if helper task directories need bundling.
258
+ - `mechanic-deploy.yml` runs manually. It requires either `task_path` or
259
+ `deploy_all=true`, always runs `mechanic tasks publish ... --dry-run`, and only
260
+ writes to Mechanic when you choose `mode=deploy`. The default is `dry-run`;
261
+ inspect that plan first, then rerun with `mode=deploy` when you mean to publish.
262
+ After a deploy, the workflow opens or updates a sync-state PR so
263
+ `.mechanic/links.json` and task hashes stay current.
264
+ - `mechanic-sync-from-app.yml` runs manually, pulls app updates with
265
+ `mechanic tasks pull --all --force`, validates the result, and opens or
266
+ updates a PR from `mechanic-sync/from-app` when files changed. The PR body
267
+ includes a changed-file summary and the V1 deletion caveat. Add a schedule to
268
+ this workflow later only if you want automatic app-to-Git sync PRs.
269
+
270
+ The pull-from-app workflow is intentionally update-only in V1: it does not prune
271
+ local files or links for tasks that were deleted in Mechanic.
272
+
273
+ ## Project Layout
274
+
275
+ ```text
276
+ mechanic.json
277
+ .mechanic/
278
+ links.json
279
+ tasks/
280
+ order-tagger.json
281
+ ```
282
+
283
+ `mechanic.json` stores the Shopify shop domain, API base URL, embedded app URL
284
+ for task links, and tasks directory. Because this file is repo-controlled, the
285
+ CLI validates configured API and app URLs before using them with a token.
286
+
287
+ `.mechanic/links.json` maps local task slugs to remote Mechanic task IDs and
288
+ the last known remote content hash. Commit this file when the repo represents a
289
+ shared source of truth for that shop.
290
+
291
+ API tokens are stored outside the project under the local user config directory,
292
+ not in `mechanic.json`. Token files are written with owner-only permissions.
293
+
294
+ ## Task Files
295
+
296
+ Canonical task files are JSON files at `tasks/<slug>.json`. They use Mechanic's
297
+ task export fields, including:
298
+
299
+ - `name`
300
+ - `script`
301
+ - `docs`
302
+ - `subscriptions_template`
303
+ - `subscriptions`
304
+ - `options`
305
+ - `preview_event_definitions`
306
+ - `tags`
307
+ - `shopify_api_version`
308
+ - `online_store_javascript`
309
+ - `order_status_javascript`
310
+
311
+ Remote IDs are never written into task JSON. `mechanic tasks publish` also
312
+ removes `enabled` from the API payload so local sync cannot accidentally enable
313
+ or disable a production task.
314
+
315
+ Direct-subscription tasks may use `null` for `script`, `docs`, and
316
+ `subscriptions_template`.
317
+
318
+ ## Bundle And Unbundle
319
+
320
+ JSON is the canonical file format, but editing long Liquid and Markdown strings
321
+ inside JSON is unpleasant. The helper workflow splits a task into separate
322
+ editable files:
323
+
324
+ ```bash
325
+ mechanic tasks unbundle tasks/order-tagger.json --out order-tagger
326
+ ```
327
+
328
+ `tasks unbundle` also accepts the same local task selectors as preview, diff,
329
+ and publish:
330
+
331
+ ```bash
332
+ mechanic tasks unbundle order-tagger
333
+ mechanic tasks unbundle tasks/order-tagger
334
+ mechanic tasks unbundle 171578bf-79e2-46af-857a-dbd71c6b7b2b
335
+ ```
336
+
337
+ ```text
338
+ order-tagger/
339
+ task.json
340
+ script.liquid
341
+ docs.md
342
+ subscriptions.liquid
343
+ online_store_javascript.js.liquid
344
+ order_status_javascript.js.liquid
345
+ ```
346
+
347
+ After editing, bundle the helper directory back to canonical JSON:
348
+
349
+ ```bash
350
+ mechanic tasks bundle tasks/order-tagger.json
351
+ mechanic tasks validate tasks/order-tagger.json
352
+ ```
353
+
354
+ `tasks bundle` accepts either side of the pair. `mechanic tasks bundle
355
+ tasks/order-tagger` writes `tasks/order-tagger.json`, and `mechanic tasks bundle
356
+ tasks/order-tagger.json` reads `tasks/order-tagger/` and writes back to that JSON
357
+ file.
358
+
359
+ When unbundling, helper files are written only for string fields. `null` fields
360
+ stay in `task.json`, and stale helper files for non-string fields are removed.
361
+
362
+ When publishing a JSON file, the CLI checks for a matching helper directory. If
363
+ `tasks/order-tagger/` has changes that are not bundled into
364
+ `tasks/order-tagger.json`, publishing stops before making any API request.
365
+
366
+ `mechanic init` adds Mechanic Liquid helper files to `.prettierignore`.
367
+ Formatter rewrites can change task behavior, especially for newline-delimited
368
+ `subscriptions.liquid` and exact Liquid string quotes in `script.liquid`.
369
+
370
+ ## Sync Safety
371
+
372
+ The CLI uses remote content hashes to avoid overwriting task edits made in the
373
+ Mechanic app or by another local checkout.
374
+
375
+ - `tasks pull` records the latest remote content hash in `.mechanic/links.json`.
376
+ - `tasks pull <task>` can refresh a linked local file without `--force` when the
377
+ local file still matches the last pulled hash. If local edits would be lost,
378
+ it stops and asks for an explicit `--force`.
379
+ - `tasks publish` sends that hash as `previous_content_hash`.
380
+ - The API rejects stale publishes with a conflict.
381
+ - `tasks diff` warns when the remote task changed since your last pull, then
382
+ compares the current Mechanic task with your local file.
383
+ - `tasks publish --all` checks every linked remote task before writing anything,
384
+ so one stale task blocks the whole publish instead of partially publishing
385
+ earlier files first.
386
+ - `tasks publish --force` bypasses the hash guard when you intentionally want
387
+ the local file to win.
388
+
389
+ Creating a new remote task sends a deterministic idempotency key for the local
390
+ shop and task slug, so retrying after a timeout returns the same remote task
391
+ instead of creating duplicates.
392
+
393
+ Before creating a task from an unlinked local file, the CLI checks existing
394
+ remote task names for the same normalized slug. If a likely match already exists
395
+ in Mechanic, publishing stops and asks you to pull/link the remote task first.
396
+
397
+ `tasks publish --all` rejects local files whose names normalize to the same task
398
+ slug, for example `foo-bar.json` and `foo_bar.json`.
399
+
400
+ ## Task Sync API
401
+
402
+ This package targets Mechanic's v1 task sync API. The CLI is the recommended
403
+ client; direct HTTP use is intended for trusted, CLI-compatible automation.
404
+
405
+ - `GET /v1/auth/verify`
406
+ - `GET /v1/shop/status`
407
+ - `GET /v1/tasks`
408
+ - `GET /v1/tasks/:id`
409
+ - `POST /v1/tasks/preview`
410
+ - `POST /v1/tasks/:id/preview`
411
+ - `POST /v1/tasks`
412
+ - `PUT /v1/tasks/:id`
413
+
414
+ Preview endpoints run local or remote task content through Mechanic's preview
415
+ engine without creating, updating, or saving a task.
416
+
417
+ Requests use Bearer token auth with API tokens created in the Mechanic app.
418
+ The CLI does not make separate telemetry calls. Authenticated API requests
419
+ include a `MechanicCLI/<version>` user agent so Mechanic can observe CLI usage
420
+ from server-side operational logs.
421
+
422
+ This API surface is intentionally narrow: task sync, task preview, and shop
423
+ status for the authenticated shop. It is not a general Mechanic API for
424
+ arbitrary integrations.
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { execute } from "@oclif/core";
4
+
5
+ await execute({ dir: import.meta.url });
package/dist/auth.d.ts ADDED
@@ -0,0 +1,10 @@
1
+ export declare function saveToken(shopDomain: string, token: string): Promise<void>;
2
+ export declare function loadToken(shopDomain: string): Promise<string | null>;
3
+ export declare function requireToken(shopDomain: string): Promise<string>;
4
+ export declare function deleteToken(shopDomain: string): Promise<void>;
5
+ export declare function canPromptForToken(): boolean;
6
+ export declare function promptForToken({ allowBlank, prompt, }?: {
7
+ allowBlank?: boolean;
8
+ prompt?: string;
9
+ }): Promise<string | null>;
10
+ //# sourceMappingURL=auth.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../src/auth.ts"],"names":[],"mappings":"AAwBA,wBAAsB,SAAS,CAAC,UAAU,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAIhF;AAED,wBAAsB,SAAS,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAG1E;AAED,wBAAsB,YAAY,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAgBtE;AAED,wBAAsB,WAAW,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAInE;AAED,wBAAgB,iBAAiB,IAAI,OAAO,CAE3C;AAED,wBAAsB,cAAc,CAAC,EACnC,UAAkB,EAClB,MAA+B,GAChC,GAAE;IACD,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,MAAM,CAAC,EAAE,MAAM,CAAC;CACZ,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAmE9B"}
package/dist/auth.js ADDED
@@ -0,0 +1,104 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { stdin, stdout } from "node:process";
4
+ import { createInterface } from "node:readline/promises";
5
+ import { Writable } from "node:stream";
6
+ import { CliError } from "./errors.js";
7
+ import { appConfigPath, ensureDir, readJson } from "./fs.js";
8
+ import { stableStringify } from "./json.js";
9
+ async function readStore() {
10
+ return readJson(appConfigPath("auth.json"), { shops: {} });
11
+ }
12
+ async function writeStore(store) {
13
+ const authPath = path.normalize(appConfigPath("auth.json"));
14
+ await ensureDir(path.dirname(authPath));
15
+ await fs.writeFile(authPath, `${stableStringify(store)}\n`, { encoding: "utf8", mode: 0o600 });
16
+ await fs.chmod(authPath, 0o600);
17
+ }
18
+ export async function saveToken(shopDomain, token) {
19
+ const store = await readStore();
20
+ store.shops[shopDomain] = { token };
21
+ await writeStore(store);
22
+ }
23
+ export async function loadToken(shopDomain) {
24
+ const store = await readStore();
25
+ return store.shops[shopDomain]?.token || null;
26
+ }
27
+ export async function requireToken(shopDomain) {
28
+ const envToken = process.env.MECHANIC_API_TOKEN?.trim();
29
+ if (envToken) {
30
+ return envToken;
31
+ }
32
+ const token = await loadToken(shopDomain);
33
+ if (!token) {
34
+ throw new CliError(`No API token available for ${shopDomain}. Set MECHANIC_API_TOKEN or run "mechanic auth login" first.`);
35
+ }
36
+ return token;
37
+ }
38
+ export async function deleteToken(shopDomain) {
39
+ const store = await readStore();
40
+ delete store.shops[shopDomain];
41
+ await writeStore(store);
42
+ }
43
+ export function canPromptForToken() {
44
+ return Boolean(stdin.isTTY);
45
+ }
46
+ export async function promptForToken({ allowBlank = false, prompt = "Mechanic API token: ", } = {}) {
47
+ if (!stdin.isTTY) {
48
+ throw new CliError("Provide a token with --token <token> or MECHANIC_API_TOKEN.");
49
+ }
50
+ let maskingToken = false;
51
+ let maskedLength = 0;
52
+ const maskedStdout = new Writable({
53
+ write(chunk, _encoding, callback) {
54
+ if (!maskingToken) {
55
+ stdout.write(chunk);
56
+ callback();
57
+ return;
58
+ }
59
+ const text = chunk.toString();
60
+ if (text === "\b \b" && maskedLength > 0) {
61
+ maskedLength -= 1;
62
+ stdout.write("\b \b");
63
+ callback();
64
+ return;
65
+ }
66
+ let maskedText = "";
67
+ for (const character of text) {
68
+ if (character === "\r" || character === "\n") {
69
+ continue;
70
+ }
71
+ if (character === "\b" || character === "\u007f") {
72
+ if (maskedLength > 0) {
73
+ maskedLength -= 1;
74
+ maskedText += "\b \b";
75
+ }
76
+ continue;
77
+ }
78
+ maskedLength += 1;
79
+ maskedText += "•";
80
+ }
81
+ stdout.write(maskedText);
82
+ callback();
83
+ },
84
+ });
85
+ const readline = createInterface({
86
+ input: stdin,
87
+ output: maskedStdout,
88
+ terminal: true,
89
+ });
90
+ try {
91
+ const tokenPromise = readline.question(prompt);
92
+ maskingToken = true;
93
+ const token = (await tokenPromise).trim();
94
+ maskingToken = false;
95
+ stdout.write("\n");
96
+ if (!token && !allowBlank) {
97
+ throw new CliError("API token cannot be blank.");
98
+ }
99
+ return token || null;
100
+ }
101
+ finally {
102
+ readline.close();
103
+ }
104
+ }
@@ -0,0 +1,20 @@
1
+ import { Command } from "@oclif/core";
2
+ import { MechanicClient } from "./client.js";
3
+ import type { Project } from "./types.js";
4
+ export declare abstract class BaseCommand extends Command {
5
+ loadProject(): Promise<Project>;
6
+ clientForProject(project: Project): Promise<MechanicClient>;
7
+ verifiedClientForProject(project: Project): Promise<MechanicClient>;
8
+ verifyClientShop(project: Project, client: MechanicClient): Promise<void>;
9
+ accent(text: string): string;
10
+ actionLabel(action: string): string;
11
+ color(color: string, text: string): string;
12
+ muted(text: string): string;
13
+ outputJson(value: unknown): void;
14
+ success(text: string): string;
15
+ lightwardAiLine(): string;
16
+ taskName(text: string): string;
17
+ taskId(project: Project, taskId: string): string;
18
+ table(rows: string[][]): void;
19
+ }
20
+ //# sourceMappingURL=base-command.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"base-command.d.ts","sourceRoot":"","sources":["../src/base-command.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAM,MAAM,aAAa,CAAC;AAI1C,OAAO,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAG7C,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,YAAY,CAAC;AAE1C,8BAAsB,WAAY,SAAQ,OAAO;IACzC,WAAW,IAAI,OAAO,CAAC,OAAO,CAAC;IAI/B,gBAAgB,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,cAAc,CAAC;IAO3D,wBAAwB,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,cAAc,CAAC;IAMnE,gBAAgB,CAAC,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,cAAc,GAAG,OAAO,CAAC,IAAI,CAAC;IAY/E,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM;IAI5B,WAAW,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM;IAenC,KAAK,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM;IAI1C,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM;IAI3B,UAAU,CAAC,KAAK,EAAE,OAAO,GAAG,IAAI;IAIhC,OAAO,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM;IAI7B,eAAe,IAAI,MAAM;IAQzB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM;IAI9B,MAAM,CAAC,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM;IAIhD,KAAK,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,GAAG,IAAI;CAgB9B"}
@@ -0,0 +1,82 @@
1
+ import { Command, ux } from "@oclif/core";
2
+ import { stripVTControlCharacters } from "node:util";
3
+ import terminalLink from "terminal-link";
4
+ import { requireToken } from "./auth.js";
5
+ import { MechanicClient } from "./client.js";
6
+ import { loadProject, taskAdminUrl } from "./config.js";
7
+ import { CliError } from "./errors.js";
8
+ export class BaseCommand extends Command {
9
+ async loadProject() {
10
+ return loadProject(process.cwd());
11
+ }
12
+ async clientForProject(project) {
13
+ return new MechanicClient({
14
+ baseUrl: project.apiBaseUrl,
15
+ token: await requireToken(project.shopDomain),
16
+ });
17
+ }
18
+ async verifiedClientForProject(project) {
19
+ const client = await this.clientForProject(project);
20
+ await this.verifyClientShop(project, client);
21
+ return client;
22
+ }
23
+ async verifyClientShop(project, client) {
24
+ const verification = await client.verifyAuth();
25
+ const verifiedShopDomain = verification.shop?.shopify_domain;
26
+ if (verifiedShopDomain !== project.shopDomain) {
27
+ throw new CliError(`API token belongs to ${verifiedShopDomain || "an unknown shop"}, but mechanic.json is configured for ${project.shopDomain}.`, 2);
28
+ }
29
+ }
30
+ accent(text) {
31
+ return this.color("#ff4fd8", text);
32
+ }
33
+ actionLabel(action) {
34
+ switch (action) {
35
+ case "created":
36
+ return this.success(action);
37
+ case "updated":
38
+ return this.color("cyan", action);
39
+ case "forced":
40
+ return this.color("yellow", action);
41
+ case "skipped":
42
+ return this.muted(action);
43
+ default:
44
+ return action;
45
+ }
46
+ }
47
+ color(color, text) {
48
+ return ux.colorize(color, text);
49
+ }
50
+ muted(text) {
51
+ return this.color("dim", text);
52
+ }
53
+ outputJson(value) {
54
+ this.log(JSON.stringify(value, null, 2));
55
+ }
56
+ success(text) {
57
+ return this.color("green", text);
58
+ }
59
+ lightwardAiLine() {
60
+ const lightwardAi = terminalLink(this.color("cyan", "Lightward AI"), "https://lightward.com", {
61
+ fallback: (text, url) => `${text}: ${this.color("cyan", url)}`,
62
+ });
63
+ return `${this.muted("Mechanic is made by Lightward. Meet")} ${lightwardAi}`;
64
+ }
65
+ taskName(text) {
66
+ return this.color("cyan", text);
67
+ }
68
+ taskId(project, taskId) {
69
+ return terminalLink(this.muted(taskId), taskAdminUrl(project, taskId), { fallback: false });
70
+ }
71
+ table(rows) {
72
+ const displayRows = rows.map((row, rowIndex) => (rowIndex === 0 ? row.map((cell) => this.muted(cell)) : row));
73
+ const visibleLength = (cell) => stripVTControlCharacters(cell).length;
74
+ const widths = displayRows[0].map((_, index) => (Math.max(...displayRows.map((row) => visibleLength(row[index] || "")))));
75
+ for (const row of displayRows) {
76
+ this.log(row.map((cell, index) => {
77
+ const padding = Math.max(0, widths[index] - visibleLength(cell));
78
+ return `${cell}${" ".repeat(padding)}`;
79
+ }).join(" ").trimEnd());
80
+ }
81
+ }
82
+ }