@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.
- package/LICENSE +21 -0
- package/README.md +424 -0
- package/bin/mechanic.js +5 -0
- package/dist/auth.d.ts +10 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +104 -0
- package/dist/base-command.d.ts +20 -0
- package/dist/base-command.d.ts.map +1 -0
- package/dist/base-command.js +82 -0
- package/dist/client.d.ts +40 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +172 -0
- package/dist/commands/auth/login.d.ts +10 -0
- package/dist/commands/auth/login.d.ts.map +1 -0
- package/dist/commands/auth/login.js +36 -0
- package/dist/commands/auth/logout.d.ts +6 -0
- package/dist/commands/auth/logout.d.ts.map +1 -0
- package/dist/commands/auth/logout.js +10 -0
- package/dist/commands/doctor.d.ts +7 -0
- package/dist/commands/doctor.d.ts.map +1 -0
- package/dist/commands/doctor.js +106 -0
- package/dist/commands/github/init.d.ts +10 -0
- package/dist/commands/github/init.d.ts.map +1 -0
- package/dist/commands/github/init.js +50 -0
- package/dist/commands/help.d.ts +7 -0
- package/dist/commands/help.d.ts.map +1 -0
- package/dist/commands/help.js +10 -0
- package/dist/commands/init.d.ts +13 -0
- package/dist/commands/init.d.ts.map +1 -0
- package/dist/commands/init.js +72 -0
- package/dist/commands/shop/status.d.ts +10 -0
- package/dist/commands/shop/status.d.ts.map +1 -0
- package/dist/commands/shop/status.js +138 -0
- package/dist/commands/tasks/bundle.d.ts +16 -0
- package/dist/commands/tasks/bundle.d.ts.map +1 -0
- package/dist/commands/tasks/bundle.js +83 -0
- package/dist/commands/tasks/diff.d.ts +16 -0
- package/dist/commands/tasks/diff.d.ts.map +1 -0
- package/dist/commands/tasks/diff.js +124 -0
- package/dist/commands/tasks/list.d.ts +11 -0
- package/dist/commands/tasks/list.d.ts.map +1 -0
- package/dist/commands/tasks/list.js +57 -0
- package/dist/commands/tasks/open.d.ts +13 -0
- package/dist/commands/tasks/open.d.ts.map +1 -0
- package/dist/commands/tasks/open.js +64 -0
- package/dist/commands/tasks/preview.d.ts +45 -0
- package/dist/commands/tasks/preview.d.ts.map +1 -0
- package/dist/commands/tasks/preview.js +373 -0
- package/dist/commands/tasks/publish.d.ts +16 -0
- package/dist/commands/tasks/publish.d.ts.map +1 -0
- package/dist/commands/tasks/publish.js +16 -0
- package/dist/commands/tasks/pull.d.ts +14 -0
- package/dist/commands/tasks/pull.d.ts.map +1 -0
- package/dist/commands/tasks/pull.js +96 -0
- package/dist/commands/tasks/push.d.ts +60 -0
- package/dist/commands/tasks/push.d.ts.map +1 -0
- package/dist/commands/tasks/push.js +370 -0
- package/dist/commands/tasks/status.d.ts +30 -0
- package/dist/commands/tasks/status.d.ts.map +1 -0
- package/dist/commands/tasks/status.js +183 -0
- package/dist/commands/tasks/unbundle.d.ts +16 -0
- package/dist/commands/tasks/unbundle.d.ts.map +1 -0
- package/dist/commands/tasks/unbundle.js +84 -0
- package/dist/commands/tasks/validate.d.ts +15 -0
- package/dist/commands/tasks/validate.d.ts.map +1 -0
- package/dist/commands/tasks/validate.js +78 -0
- package/dist/config.d.ts +15 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +227 -0
- package/dist/errors.d.ts +10 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +18 -0
- package/dist/fs.d.ts +10 -0
- package/dist/fs.d.ts.map +1 -0
- package/dist/fs.js +51 -0
- package/dist/github-workflows.d.ts +6 -0
- package/dist/github-workflows.d.ts.map +1 -0
- package/dist/github-workflows.js +293 -0
- package/dist/hash.d.ts +2 -0
- package/dist/hash.d.ts.map +1 -0
- package/dist/hash.js +5 -0
- package/dist/json.d.ts +4 -0
- package/dist/json.d.ts.map +1 -0
- package/dist/json.js +30 -0
- package/dist/tasks.d.ts +48 -0
- package/dist/tasks.d.ts.map +1 -0
- package/dist/tasks.js +546 -0
- package/dist/types.d.ts +144 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +1 -0
- package/package.json +80 -0
- package/schemas/mechanic.schema.json +13 -0
- 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.
|
package/bin/mechanic.js
ADDED
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
|
+
}
|