@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
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { Args, Flags } from "@oclif/core";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { BaseCommand } from "../../base-command.js";
|
|
4
|
+
import { CliError } from "../../errors.js";
|
|
5
|
+
import { pathExists } from "../../fs.js";
|
|
6
|
+
import { displayTaskPath, resolveTaskSelector, unbundleTask } from "../../tasks.js";
|
|
7
|
+
export default class TasksUnbundle extends BaseCommand {
|
|
8
|
+
static summary = "Unbundle one task into one helper directory.";
|
|
9
|
+
static description = "Unbundle one canonical task JSON file, linked task ID, helper directory, or unique local task slug into one editable helper task directory.";
|
|
10
|
+
static examples = [
|
|
11
|
+
"$ mechanic tasks unbundle tasks/order-tagger.json",
|
|
12
|
+
"$ mechanic tasks unbundle order-tagger",
|
|
13
|
+
"$ mechanic tasks unbundle 171578bf-79e2-46af-857a-dbd71c6b7b2b",
|
|
14
|
+
"$ mechanic tasks unbundle tasks/order-tagger.json --out helpers/order-tagger",
|
|
15
|
+
];
|
|
16
|
+
static args = {
|
|
17
|
+
file: Args.string({ required: false, description: "Task file, helper directory, linked task ID, or unique local task slug." }),
|
|
18
|
+
};
|
|
19
|
+
static flags = {
|
|
20
|
+
json: Flags.boolean({
|
|
21
|
+
description: "Print unbundle result as JSON for agents, scripts, or editor integrations.",
|
|
22
|
+
}),
|
|
23
|
+
out: Flags.string({
|
|
24
|
+
description: "Output helper task directory. Defaults to the task file path without .json.",
|
|
25
|
+
}),
|
|
26
|
+
};
|
|
27
|
+
async run() {
|
|
28
|
+
const { args, flags } = await this.parse(TasksUnbundle);
|
|
29
|
+
if (!args.file) {
|
|
30
|
+
throw new CliError([
|
|
31
|
+
"Choose one task to unbundle.",
|
|
32
|
+
"",
|
|
33
|
+
"Example:",
|
|
34
|
+
" mechanic tasks unbundle tasks/order-tagger.json",
|
|
35
|
+
"",
|
|
36
|
+
"By default this writes to tasks/order-tagger/. Use --out <dir> to choose another helper directory.",
|
|
37
|
+
].join("\n"), 2);
|
|
38
|
+
}
|
|
39
|
+
const { filePath, inputDisplay, outputPath, outputDisplay } = await this.resolveUnbundlePaths(args.file, flags.out);
|
|
40
|
+
await unbundleTask(filePath, outputPath);
|
|
41
|
+
if (flags.json) {
|
|
42
|
+
this.outputJson({
|
|
43
|
+
ok: true,
|
|
44
|
+
input: inputDisplay,
|
|
45
|
+
input_path: filePath,
|
|
46
|
+
output: outputDisplay,
|
|
47
|
+
output_path: outputPath,
|
|
48
|
+
});
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
this.log(`Unbundled ${this.taskName(inputDisplay)} -> ${this.taskName(outputDisplay)}`);
|
|
52
|
+
}
|
|
53
|
+
async resolveUnbundlePaths(input, out) {
|
|
54
|
+
const inputPath = path.resolve(input);
|
|
55
|
+
if (input.toLowerCase().endsWith(".json")) {
|
|
56
|
+
const outputDisplay = out || input.replace(/\.json$/i, "");
|
|
57
|
+
return {
|
|
58
|
+
filePath: inputPath,
|
|
59
|
+
inputDisplay: input,
|
|
60
|
+
outputPath: path.resolve(outputDisplay),
|
|
61
|
+
outputDisplay,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
const project = await this.loadProject();
|
|
65
|
+
const selector = await resolveTaskSelector(project, input);
|
|
66
|
+
if (!selector.file) {
|
|
67
|
+
throw new CliError(`No local task file found for ${input}. Run "mechanic tasks pull" first.`, 2);
|
|
68
|
+
}
|
|
69
|
+
if (!(await pathExists(selector.file))) {
|
|
70
|
+
throw new CliError([
|
|
71
|
+
`No local task file found for ${input}.`,
|
|
72
|
+
`Run "mechanic tasks pull ${selector.remoteId || input}" first.`,
|
|
73
|
+
].join("\n"), 2);
|
|
74
|
+
}
|
|
75
|
+
const outputPath = out ? path.resolve(out) : selector.file.replace(/\.json$/i, "");
|
|
76
|
+
const outputDisplay = out || displayTaskPath(project, outputPath);
|
|
77
|
+
return {
|
|
78
|
+
filePath: selector.file,
|
|
79
|
+
inputDisplay: displayTaskPath(project, selector.file),
|
|
80
|
+
outputPath,
|
|
81
|
+
outputDisplay,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { BaseCommand } from "../../base-command.js";
|
|
2
|
+
import { type ValidationResult } from "../../tasks.js";
|
|
3
|
+
export default class TasksValidate extends BaseCommand {
|
|
4
|
+
static summary: string;
|
|
5
|
+
static description: string;
|
|
6
|
+
static args: {
|
|
7
|
+
target: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
|
|
8
|
+
};
|
|
9
|
+
static flags: {
|
|
10
|
+
json: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
11
|
+
};
|
|
12
|
+
run(): Promise<void>;
|
|
13
|
+
validateTaskFile(file: string): Promise<ValidationResult>;
|
|
14
|
+
}
|
|
15
|
+
//# sourceMappingURL=validate.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"validate.d.ts","sourceRoot":"","sources":["../../../src/commands/tasks/validate.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAC;AAGpD,OAAO,EAKL,KAAK,gBAAgB,EACtB,MAAM,gBAAgB,CAAC;AAMxB,MAAM,CAAC,OAAO,OAAO,aAAc,SAAQ,WAAW;IACpD,OAAgB,OAAO,SAAgF;IACvG,OAAgB,WAAW,SAA0G;IAErI,OAAgB,IAAI;;MAElB;IAEF,OAAgB,KAAK;;MAInB;IAEI,GAAG,IAAI,OAAO,CAAC,IAAI,CAAC;IAiDpB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,gBAAgB,CAAC;CAchE"}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { Args, Flags } from "@oclif/core";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { BaseCommand } from "../../base-command.js";
|
|
5
|
+
import { CliError } from "../../errors.js";
|
|
6
|
+
import { pathExists } from "../../fs.js";
|
|
7
|
+
import { rawTaskFromHelperDir, readRawTaskFile, unbundledHelperDirForTaskFile, validateTask, } from "../../tasks.js";
|
|
8
|
+
function errorMessage(error) {
|
|
9
|
+
return error instanceof Error ? error.message : String(error);
|
|
10
|
+
}
|
|
11
|
+
export default class TasksValidate extends BaseCommand {
|
|
12
|
+
static summary = "Validate one task file, helper directory, or directory of task JSON files.";
|
|
13
|
+
static description = "Validate one canonical task JSON file, one helper task directory, or a directory of task JSON files.";
|
|
14
|
+
static args = {
|
|
15
|
+
target: Args.string({ required: true, description: "Task JSON file, helper dir, or directory of task JSON files." }),
|
|
16
|
+
};
|
|
17
|
+
static flags = {
|
|
18
|
+
json: Flags.boolean({
|
|
19
|
+
description: "Print validation results as JSON for agents, scripts, or editor integrations.",
|
|
20
|
+
}),
|
|
21
|
+
};
|
|
22
|
+
async run() {
|
|
23
|
+
const { args, flags } = await this.parse(TasksValidate);
|
|
24
|
+
const target = path.resolve(args.target);
|
|
25
|
+
const results = [];
|
|
26
|
+
const stat = await fs.stat(target).catch((error) => {
|
|
27
|
+
throw new CliError(`Unable to access ${target}: ${errorMessage(error)}`, 2);
|
|
28
|
+
});
|
|
29
|
+
if (stat.isFile()) {
|
|
30
|
+
results.push(await this.validateTaskFile(target));
|
|
31
|
+
}
|
|
32
|
+
else if (await pathExists(path.join(target, "task.json"))) {
|
|
33
|
+
results.push(validateTask(await rawTaskFromHelperDir(target), target));
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
const entries = await fs.readdir(target, { withFileTypes: true }).catch((error) => {
|
|
37
|
+
throw new CliError(`Unable to read directory ${target}: ${errorMessage(error)}`, 2);
|
|
38
|
+
});
|
|
39
|
+
const files = entries
|
|
40
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
|
|
41
|
+
.map((entry) => path.join(target, entry.name))
|
|
42
|
+
.sort();
|
|
43
|
+
for (const file of files) {
|
|
44
|
+
results.push(await this.validateTaskFile(file));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (results.length === 0) {
|
|
48
|
+
throw new CliError(`No task JSON files found in ${target}.`);
|
|
49
|
+
}
|
|
50
|
+
if (flags.json) {
|
|
51
|
+
this.outputJson({
|
|
52
|
+
ok: results.every((result) => result.ok),
|
|
53
|
+
results,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
for (const result of results) {
|
|
58
|
+
this.log(`${result.ok ? this.success("OK") : this.color("red", "ERROR")} ${this.taskName(result.source)}`);
|
|
59
|
+
for (const error of result.errors) {
|
|
60
|
+
this.log(` ${this.color("red", error)}`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (results.some((result) => !result.ok)) {
|
|
65
|
+
throw new CliError("Task validation failed.", 2);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
async validateTaskFile(file) {
|
|
69
|
+
const task = await readRawTaskFile(file);
|
|
70
|
+
const result = validateTask(task, file);
|
|
71
|
+
const helperDir = await unbundledHelperDirForTaskFile(file, task);
|
|
72
|
+
if (helperDir) {
|
|
73
|
+
result.ok = false;
|
|
74
|
+
result.errors.push(`Helper directory ${path.relative(process.cwd(), helperDir) || helperDir} has changes that are not bundled into this JSON file.`);
|
|
75
|
+
}
|
|
76
|
+
return result;
|
|
77
|
+
}
|
|
78
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { LinkEntry, LinksFile, MechanicConfig, Project } from "./types.js";
|
|
2
|
+
export declare const DEFAULT_API_BASE_URL: string;
|
|
3
|
+
export declare function normalizeApiBaseUrl(value: string): string;
|
|
4
|
+
export declare function defaultAppUrl(shopDomain: string, appAlias?: string): string;
|
|
5
|
+
export declare function taskAdminUrl(project: Project, taskId: string): string;
|
|
6
|
+
export declare function defaultConfig(shopDomain: string, apiBaseUrl?: string, appUrl?: string): MechanicConfig;
|
|
7
|
+
export declare function emptyLinks(): LinksFile;
|
|
8
|
+
export declare function initProject(cwd: string, shopDomain: string, apiBaseUrl?: string, appUrl?: string, force?: boolean): Promise<Project>;
|
|
9
|
+
export declare function ensureMechanicPrettierIgnore(cwd: string): Promise<boolean>;
|
|
10
|
+
export declare function loadProject(cwd?: string): Promise<Project>;
|
|
11
|
+
export declare function saveLinks(project: Project, links: LinksFile): Promise<void>;
|
|
12
|
+
export declare function linkForSlug(project: Project, slug: string): LinkEntry | null;
|
|
13
|
+
export declare function slugForRemoteId(project: Project, remoteId: string): string | null;
|
|
14
|
+
export declare function updateLink(project: Project, slug: string, remoteId: string, contentHash: string): LinksFile;
|
|
15
|
+
//# sourceMappingURL=config.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,SAAS,EAAE,SAAS,EAAE,cAAc,EAAE,OAAO,EAAE,MAAM,YAAY,CAAC;AAEhF,eAAO,MAAM,oBAAoB,QAAkE,CAAC;AA0BpG,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAyBzD;AA+BD,wBAAgB,aAAa,CAAC,UAAU,EAAE,MAAM,EAAE,QAAQ,SAA4B,GAAG,MAAM,CAG9F;AAED,wBAAgB,YAAY,CAAC,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAMrE;AAED,wBAAgB,aAAa,CAC3B,UAAU,EAAE,MAAM,EAClB,UAAU,SAAuB,EACjC,MAAM,SAA4D,GACjE,cAAc,CAOhB;AAED,wBAAgB,UAAU,IAAI,SAAS,CAEtC;AAkED,wBAAsB,WAAW,CAC/B,GAAG,EAAE,MAAM,EACX,UAAU,EAAE,MAAM,EAClB,UAAU,CAAC,EAAE,MAAM,EACnB,MAAM,CAAC,EAAE,MAAM,EACf,KAAK,UAAQ,GACZ,OAAO,CAAC,OAAO,CAAC,CAyBlB;AAED,wBAAsB,4BAA4B,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CA0BhF;AAED,wBAAsB,WAAW,CAAC,GAAG,SAAgB,GAAG,OAAO,CAAC,OAAO,CAAC,CAkCvE;AAED,wBAAsB,SAAS,CAAC,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,SAAS,GAAG,OAAO,CAAC,IAAI,CAAC,CAGjF;AAED,wBAAgB,WAAW,CAAC,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,CAE5E;AAED,wBAAgB,eAAe,CAAC,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAEjF;AAED,wBAAgB,UAAU,CAAC,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,SAAS,CAW3G"}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { CliError } from "./errors.js";
|
|
3
|
+
import { ensureDir, pathExists, readJson, readText, writeJson, writeText } from "./fs.js";
|
|
4
|
+
export const DEFAULT_API_BASE_URL = process.env.MECHANIC_API_BASE_URL || "https://api.mechanic.dev";
|
|
5
|
+
const TRUSTED_API_HOSTS = new Set([
|
|
6
|
+
"api.mechanic.dev",
|
|
7
|
+
]);
|
|
8
|
+
const DEFAULT_SHOPIFY_APP_ALIAS = "mechanic";
|
|
9
|
+
const DEFAULT_TASKS_DIR = "tasks";
|
|
10
|
+
const MECHANIC_PRETTIERIGNORE_COMMENT = "# Mechanic Liquid files are executable task source; formatting can change behavior.";
|
|
11
|
+
const MECHANIC_PRETTIERIGNORE_PATTERNS = [
|
|
12
|
+
"**/script.liquid",
|
|
13
|
+
"**/subscriptions.liquid",
|
|
14
|
+
"**/online_store_javascript.js.liquid",
|
|
15
|
+
"**/order_status_javascript.js.liquid",
|
|
16
|
+
];
|
|
17
|
+
function isLoopbackHost(hostname) {
|
|
18
|
+
return ["localhost", "127.0.0.1", "::1", "[::1]"].includes(hostname.toLowerCase());
|
|
19
|
+
}
|
|
20
|
+
function parseConfigUrl(value, label) {
|
|
21
|
+
try {
|
|
22
|
+
return new URL(value);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
throw new CliError(`${label} must be a valid URL.`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
export function normalizeApiBaseUrl(value) {
|
|
29
|
+
const url = parseConfigUrl(value, "api_base_url");
|
|
30
|
+
const hostname = url.hostname.toLowerCase();
|
|
31
|
+
if (!["http:", "https:"].includes(url.protocol)) {
|
|
32
|
+
throw new CliError("api_base_url must use http or https.");
|
|
33
|
+
}
|
|
34
|
+
const trustedHost = ((url.protocol === "https:" && TRUSTED_API_HOSTS.has(hostname)));
|
|
35
|
+
if (url.username || url.password || url.search || url.hash || !["", "/"].includes(url.pathname)) {
|
|
36
|
+
throw new CliError("api_base_url must be a plain Mechanic API origin, like https://api.mechanic.dev.");
|
|
37
|
+
}
|
|
38
|
+
if (!trustedHost && process.env.MECHANIC_TRUST_API_BASE_URL !== "1") {
|
|
39
|
+
throw new CliError([
|
|
40
|
+
`Untrusted Mechanic API base URL: ${url.origin}`,
|
|
41
|
+
"To protect API tokens, mechanic only sends tokens to api.mechanic.dev by default.",
|
|
42
|
+
"Set MECHANIC_TRUST_API_BASE_URL=1 only for a URL you control.",
|
|
43
|
+
].join("\n"), 2);
|
|
44
|
+
}
|
|
45
|
+
return url.origin;
|
|
46
|
+
}
|
|
47
|
+
function normalizeAppUrl(value) {
|
|
48
|
+
const url = parseConfigUrl(value, "app_url");
|
|
49
|
+
const hostname = url.hostname.toLowerCase();
|
|
50
|
+
if (!["http:", "https:"].includes(url.protocol)) {
|
|
51
|
+
throw new CliError("app_url must use http or https.");
|
|
52
|
+
}
|
|
53
|
+
const trustedHost = ((url.protocol === "https:" && hostname === "admin.shopify.com")
|
|
54
|
+
|| (["http:", "https:"].includes(url.protocol) && isLoopbackHost(hostname) && process.env.MECHANIC_TRUST_APP_URL === "1"));
|
|
55
|
+
if (url.username || url.password) {
|
|
56
|
+
throw new CliError("app_url must not include credentials.");
|
|
57
|
+
}
|
|
58
|
+
if (!trustedHost && process.env.MECHANIC_TRUST_APP_URL !== "1") {
|
|
59
|
+
throw new CliError([
|
|
60
|
+
`Untrusted Mechanic app URL: ${url.origin}`,
|
|
61
|
+
"Task links open in the Shopify admin by default. Use an admin.shopify.com URL, or set MECHANIC_TRUST_APP_URL=1 only for a URL you control.",
|
|
62
|
+
].join("\n"), 2);
|
|
63
|
+
}
|
|
64
|
+
url.search = "";
|
|
65
|
+
url.hash = "";
|
|
66
|
+
return url.toString().replace(/\/+$/, "");
|
|
67
|
+
}
|
|
68
|
+
export function defaultAppUrl(shopDomain, appAlias = DEFAULT_SHOPIFY_APP_ALIAS) {
|
|
69
|
+
const shopHandle = shopDomain.replace(/\.myshopify\.com$/i, "");
|
|
70
|
+
return `https://admin.shopify.com/store/${shopHandle}/apps/${appAlias}`;
|
|
71
|
+
}
|
|
72
|
+
export function taskAdminUrl(project, taskId) {
|
|
73
|
+
const appUrl = new URL(project.appUrl);
|
|
74
|
+
appUrl.pathname = `${appUrl.pathname.replace(/\/+$/, "")}/tasks/${encodeURIComponent(taskId)}`;
|
|
75
|
+
appUrl.search = "";
|
|
76
|
+
appUrl.hash = "";
|
|
77
|
+
return appUrl.toString();
|
|
78
|
+
}
|
|
79
|
+
export function defaultConfig(shopDomain, apiBaseUrl = DEFAULT_API_BASE_URL, appUrl = process.env.MECHANIC_APP_URL || defaultAppUrl(shopDomain)) {
|
|
80
|
+
return {
|
|
81
|
+
shop_domain: shopDomain,
|
|
82
|
+
api_base_url: apiBaseUrl,
|
|
83
|
+
app_url: appUrl,
|
|
84
|
+
tasks_dir: DEFAULT_TASKS_DIR,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
export function emptyLinks() {
|
|
88
|
+
return { tasks: {} };
|
|
89
|
+
}
|
|
90
|
+
function normalizeLinkEntry(slug, entry) {
|
|
91
|
+
if (!entry || typeof entry !== "object" || !entry.remote_id || !entry.last_remote_content_hash) {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
return [slug, {
|
|
95
|
+
file: entry.file || `${DEFAULT_TASKS_DIR}/${slug}.json`,
|
|
96
|
+
remote_id: entry.remote_id,
|
|
97
|
+
last_remote_content_hash: entry.last_remote_content_hash,
|
|
98
|
+
}];
|
|
99
|
+
}
|
|
100
|
+
function normalizeLegacyLinkEntry(slug, entry) {
|
|
101
|
+
if (!entry || typeof entry !== "object" || !entry.task_id || !entry.last_remote_hash) {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
return [slug, {
|
|
105
|
+
file: `${DEFAULT_TASKS_DIR}/${slug}.json`,
|
|
106
|
+
remote_id: entry.task_id,
|
|
107
|
+
last_remote_content_hash: entry.last_remote_hash,
|
|
108
|
+
}];
|
|
109
|
+
}
|
|
110
|
+
function normalizeLinks(value, shopDomain) {
|
|
111
|
+
if (!value || typeof value !== "object") {
|
|
112
|
+
return emptyLinks();
|
|
113
|
+
}
|
|
114
|
+
const links = value;
|
|
115
|
+
if (links.tasks && typeof links.tasks === "object") {
|
|
116
|
+
return {
|
|
117
|
+
tasks: Object.fromEntries(Object.entries(links.tasks).flatMap(([slug, entry]) => {
|
|
118
|
+
const normalized = normalizeLinkEntry(slug, entry);
|
|
119
|
+
return normalized ? [normalized] : [];
|
|
120
|
+
})),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
const shopLinks = links.shops?.[shopDomain]?.tasks;
|
|
124
|
+
if (shopLinks && typeof shopLinks === "object") {
|
|
125
|
+
return {
|
|
126
|
+
tasks: Object.fromEntries(Object.entries(shopLinks).flatMap(([slug, entry]) => {
|
|
127
|
+
const normalized = normalizeLegacyLinkEntry(slug, entry);
|
|
128
|
+
return normalized ? [normalized] : [];
|
|
129
|
+
})),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
return emptyLinks();
|
|
133
|
+
}
|
|
134
|
+
export async function initProject(cwd, shopDomain, apiBaseUrl, appUrl, force = false) {
|
|
135
|
+
const config = defaultConfig(shopDomain, apiBaseUrl, appUrl);
|
|
136
|
+
const configPath = path.join(cwd, "mechanic.json");
|
|
137
|
+
const linksPath = path.join(cwd, ".mechanic", "links.json");
|
|
138
|
+
if (!force && ((await pathExists(configPath)) || (await pathExists(linksPath)))) {
|
|
139
|
+
throw new CliError([
|
|
140
|
+
"Mechanic project files already exist.",
|
|
141
|
+
"Re-run with --force to overwrite mechanic.json while preserving existing task links.",
|
|
142
|
+
].join("\n"), 2);
|
|
143
|
+
}
|
|
144
|
+
const existingConfig = force ? await readJson(configPath, {}) : {};
|
|
145
|
+
const preserveLinks = force && existingConfig.shop_domain === shopDomain;
|
|
146
|
+
const links = preserveLinks ? normalizeLinks(await readJson(linksPath, emptyLinks()), shopDomain) : emptyLinks();
|
|
147
|
+
config.api_base_url = normalizeApiBaseUrl(config.api_base_url);
|
|
148
|
+
config.app_url = normalizeAppUrl(config.app_url || defaultAppUrl(config.shop_domain));
|
|
149
|
+
await ensureDir(path.join(cwd, config.tasks_dir));
|
|
150
|
+
await ensureDir(path.dirname(linksPath));
|
|
151
|
+
await writeJson(configPath, config);
|
|
152
|
+
await writeJson(linksPath, links);
|
|
153
|
+
return loadProject(cwd);
|
|
154
|
+
}
|
|
155
|
+
export async function ensureMechanicPrettierIgnore(cwd) {
|
|
156
|
+
const filePath = path.join(cwd, ".prettierignore");
|
|
157
|
+
const existing = await readText(filePath, "");
|
|
158
|
+
const existingLines = new Set(existing
|
|
159
|
+
.split(/\r?\n/)
|
|
160
|
+
.map((line) => line.trim())
|
|
161
|
+
.filter(Boolean));
|
|
162
|
+
const missingPatterns = MECHANIC_PRETTIERIGNORE_PATTERNS.filter((pattern) => !existingLines.has(pattern));
|
|
163
|
+
if (missingPatterns.length === 0) {
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
const additionLines = [
|
|
167
|
+
...(existing.includes(MECHANIC_PRETTIERIGNORE_COMMENT) ? [] : [MECHANIC_PRETTIERIGNORE_COMMENT]),
|
|
168
|
+
...missingPatterns,
|
|
169
|
+
];
|
|
170
|
+
const prefix = [
|
|
171
|
+
existing.length > 0 && !existing.endsWith("\n") ? "\n" : "",
|
|
172
|
+
existing.trim().length > 0 ? "\n" : "",
|
|
173
|
+
].join("");
|
|
174
|
+
await writeText(filePath, `${existing}${prefix}${additionLines.join("\n")}\n`);
|
|
175
|
+
return true;
|
|
176
|
+
}
|
|
177
|
+
export async function loadProject(cwd = process.cwd()) {
|
|
178
|
+
const configPath = path.join(cwd, "mechanic.json");
|
|
179
|
+
if (!(await pathExists(configPath))) {
|
|
180
|
+
throw new CliError(`No mechanic.json found in ${cwd}. Run "mechanic init --shop <domain>" first.`);
|
|
181
|
+
}
|
|
182
|
+
const config = await readJson(configPath);
|
|
183
|
+
if (!config.shop_domain) {
|
|
184
|
+
throw new CliError("mechanic.json must include shop_domain");
|
|
185
|
+
}
|
|
186
|
+
if (!config.api_base_url) {
|
|
187
|
+
throw new CliError("mechanic.json must include api_base_url");
|
|
188
|
+
}
|
|
189
|
+
const tasksDirName = config.tasks_dir || DEFAULT_TASKS_DIR;
|
|
190
|
+
const linksPath = path.join(cwd, ".mechanic", "links.json");
|
|
191
|
+
const links = normalizeLinks(await readJson(linksPath, emptyLinks()), config.shop_domain);
|
|
192
|
+
const apiBaseUrl = normalizeApiBaseUrl(process.env.MECHANIC_API_BASE_URL || config.api_base_url);
|
|
193
|
+
const appUrl = normalizeAppUrl(process.env.MECHANIC_APP_URL || config.app_url || defaultAppUrl(config.shop_domain));
|
|
194
|
+
return {
|
|
195
|
+
cwd,
|
|
196
|
+
configPath,
|
|
197
|
+
linksPath,
|
|
198
|
+
shopDomain: config.shop_domain,
|
|
199
|
+
apiBaseUrl,
|
|
200
|
+
appUrl,
|
|
201
|
+
tasksDir: path.join(cwd, tasksDirName),
|
|
202
|
+
tasksDirName,
|
|
203
|
+
links,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
export async function saveLinks(project, links) {
|
|
207
|
+
await writeJson(project.linksPath, links);
|
|
208
|
+
project.links = links;
|
|
209
|
+
}
|
|
210
|
+
export function linkForSlug(project, slug) {
|
|
211
|
+
return project.links.tasks[slug] || null;
|
|
212
|
+
}
|
|
213
|
+
export function slugForRemoteId(project, remoteId) {
|
|
214
|
+
return Object.entries(project.links.tasks).find(([, link]) => link.remote_id === remoteId)?.[0] || null;
|
|
215
|
+
}
|
|
216
|
+
export function updateLink(project, slug, remoteId, contentHash) {
|
|
217
|
+
return {
|
|
218
|
+
tasks: {
|
|
219
|
+
...project.links.tasks,
|
|
220
|
+
[slug]: {
|
|
221
|
+
file: `${project.tasksDirName}/${slug}.json`,
|
|
222
|
+
remote_id: remoteId,
|
|
223
|
+
last_remote_content_hash: contentHash,
|
|
224
|
+
},
|
|
225
|
+
},
|
|
226
|
+
};
|
|
227
|
+
}
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export declare class CliError extends Error {
|
|
2
|
+
readonly exitCode: number;
|
|
3
|
+
constructor(message: string, exitCode?: number);
|
|
4
|
+
}
|
|
5
|
+
export declare class HttpError extends CliError {
|
|
6
|
+
readonly status: number;
|
|
7
|
+
readonly body: unknown;
|
|
8
|
+
constructor(message: string, status: number, body: unknown);
|
|
9
|
+
}
|
|
10
|
+
//# sourceMappingURL=errors.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA,qBAAa,QAAS,SAAQ,KAAK;IACjC,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;gBAEd,OAAO,EAAE,MAAM,EAAE,QAAQ,SAAI;CAK1C;AAED,qBAAa,SAAU,SAAQ,QAAQ;IACrC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC;gBAEX,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,OAAO;CAM3D"}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export class CliError extends Error {
|
|
2
|
+
exitCode;
|
|
3
|
+
constructor(message, exitCode = 1) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.name = "Error";
|
|
6
|
+
this.exitCode = exitCode;
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
export class HttpError extends CliError {
|
|
10
|
+
status;
|
|
11
|
+
body;
|
|
12
|
+
constructor(message, status, body) {
|
|
13
|
+
super(message, status === 409 ? 2 : 1);
|
|
14
|
+
this.name = "HttpError";
|
|
15
|
+
this.status = status;
|
|
16
|
+
this.body = body;
|
|
17
|
+
}
|
|
18
|
+
}
|
package/dist/fs.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export declare function pathExists(filePath: string): Promise<boolean>;
|
|
2
|
+
export declare function ensureDir(directoryPath: string): Promise<void>;
|
|
3
|
+
export declare function readText(filePath: string, fallback?: string): Promise<string>;
|
|
4
|
+
export declare function writeText(filePath: string, value: string): Promise<void>;
|
|
5
|
+
export declare function removeFile(filePath: string): Promise<void>;
|
|
6
|
+
export declare function readJson<T>(filePath: string, fallback?: T): Promise<T>;
|
|
7
|
+
export declare function writeJson(filePath: string, value: unknown): Promise<void>;
|
|
8
|
+
export declare function configHome(): string;
|
|
9
|
+
export declare function appConfigPath(...segments: string[]): string;
|
|
10
|
+
//# sourceMappingURL=fs.d.ts.map
|
package/dist/fs.d.ts.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"fs.d.ts","sourceRoot":"","sources":["../src/fs.ts"],"names":[],"mappings":"AAMA,wBAAsB,UAAU,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAOnE;AAED,wBAAsB,SAAS,CAAC,aAAa,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAEpE;AAED,wBAAsB,QAAQ,CAAC,QAAQ,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAUnF;AAED,wBAAsB,SAAS,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAG9E;AAED,wBAAsB,UAAU,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAEhE;AAED,wBAAsB,QAAQ,CAAC,CAAC,EAAE,QAAQ,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAU5E;AAED,wBAAsB,SAAS,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC,CAE/E;AAED,wBAAgB,UAAU,IAAI,MAAM,CAEnC;AAED,wBAAgB,aAAa,CAAC,GAAG,QAAQ,EAAE,MAAM,EAAE,GAAG,MAAM,CAE3D"}
|
package/dist/fs.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { CliError } from "./errors.js";
|
|
5
|
+
import { parseJson, stableStringify } from "./json.js";
|
|
6
|
+
export async function pathExists(filePath) {
|
|
7
|
+
try {
|
|
8
|
+
await fs.access(filePath);
|
|
9
|
+
return true;
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export async function ensureDir(directoryPath) {
|
|
16
|
+
await fs.mkdir(directoryPath, { recursive: true });
|
|
17
|
+
}
|
|
18
|
+
export async function readText(filePath, fallback) {
|
|
19
|
+
if (!(await pathExists(filePath))) {
|
|
20
|
+
if (fallback !== undefined) {
|
|
21
|
+
return fallback;
|
|
22
|
+
}
|
|
23
|
+
throw new CliError(`Missing required file: ${filePath}`);
|
|
24
|
+
}
|
|
25
|
+
return fs.readFile(filePath, "utf8");
|
|
26
|
+
}
|
|
27
|
+
export async function writeText(filePath, value) {
|
|
28
|
+
await ensureDir(path.dirname(filePath));
|
|
29
|
+
await fs.writeFile(filePath, value, "utf8");
|
|
30
|
+
}
|
|
31
|
+
export async function removeFile(filePath) {
|
|
32
|
+
await fs.rm(filePath, { force: true });
|
|
33
|
+
}
|
|
34
|
+
export async function readJson(filePath, fallback) {
|
|
35
|
+
if (!(await pathExists(filePath))) {
|
|
36
|
+
if (fallback !== undefined) {
|
|
37
|
+
return fallback;
|
|
38
|
+
}
|
|
39
|
+
throw new CliError(`Missing required file: ${filePath}`);
|
|
40
|
+
}
|
|
41
|
+
return parseJson(await fs.readFile(filePath, "utf8"), filePath);
|
|
42
|
+
}
|
|
43
|
+
export async function writeJson(filePath, value) {
|
|
44
|
+
await writeText(filePath, `${stableStringify(value)}\n`);
|
|
45
|
+
}
|
|
46
|
+
export function configHome() {
|
|
47
|
+
return process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
|
|
48
|
+
}
|
|
49
|
+
export function appConfigPath(...segments) {
|
|
50
|
+
return path.join(configHome(), "mechanic-cli", ...segments);
|
|
51
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"github-workflows.d.ts","sourceRoot":"","sources":["../src/github-workflows.ts"],"names":[],"mappings":"AAMA,MAAM,MAAM,kBAAkB,GAAG;IAC/B,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;CAClB,CAAC;AAuRF,wBAAgB,mBAAmB,IAAI,kBAAkB,EAAE,CAe1D"}
|