@morit/cli 1.0.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/src/cli.js ADDED
@@ -0,0 +1,414 @@
1
+ import { spawn } from "node:child_process";
2
+ import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
3
+ import { basename, dirname, join, resolve } from "node:path";
4
+ import { MoritCloudClient, MoritCloudError } from "./cloud-client.js";
5
+ import { filesDigest, readProjectConfig, writeProjectConfig } from "./config.js";
6
+ import { clearCredential, loadCredential, saveCredential } from "./secure-store.js";
7
+ import {
8
+ buildProjectDirectory,
9
+ internal as sdkInternal,
10
+ readProjectDirectory,
11
+ scaffoldProjectDirectory,
12
+ validateProjectDirectory,
13
+ } from "./workspace.js";
14
+
15
+ const DEFAULT_API_URL = "https://developers.moring.co";
16
+ const HELP = `Official Morit Developer CLI
17
+
18
+ Usage:
19
+ morit login [--token <access-token>] [--api-url <origin>]
20
+ morit logout
21
+ morit plugin setup [directory] --id <plugin.id> --name <name> --publisher <publisher>
22
+ morit plugin add [directory]
23
+ morit plugin validate [directory]
24
+ morit plugin build [directory] [--output <file.mplg>]
25
+ morit plugin sync [directory] [--pull] [--force]
26
+ morit plugin deploy [directory] [--visibility private|public] [--file-name <file.mplg>]
27
+ morit project list
28
+ morit project get <project-id>
29
+ morit project delete <project-id>
30
+ morit deployment list [project-id]
31
+ morit deployment get <deployment-id>
32
+ morit deployment publish <deployment-id> --visibility private|public
33
+
34
+ Use --json for machine-readable output. Credentials are stored in the operating-system keyring.
35
+ `;
36
+
37
+ function parseArguments(argv) {
38
+ const result = { positionals: [], flags: {} };
39
+ const booleans = new Set(["--json", "--pull", "--force"]);
40
+ for (let index = 0; index < argv.length; index += 1) {
41
+ const value = argv[index];
42
+ if (!value.startsWith("--")) {
43
+ result.positionals.push(value);
44
+ continue;
45
+ }
46
+ if (booleans.has(value)) {
47
+ result.flags[value.slice(2)] = true;
48
+ continue;
49
+ }
50
+ const next = argv[index + 1];
51
+ if (!next || next.startsWith("--")) throw new Error(`${value} requires a value`);
52
+ result.flags[value.slice(2)] = next;
53
+ index += 1;
54
+ }
55
+ return result;
56
+ }
57
+
58
+ function output(stdout, value, json = false) {
59
+ if (json) {
60
+ stdout.write(`${JSON.stringify(value, null, 2)}\n`);
61
+ return;
62
+ }
63
+ if (typeof value === "string") stdout.write(`${value}\n`);
64
+ else stdout.write(`${JSON.stringify(value, null, 2)}\n`);
65
+ }
66
+
67
+ function openBrowser(url) {
68
+ const [command, args] = process.platform === "win32"
69
+ ? ["rundll32.exe", ["url.dll,FileProtocolHandler", url]]
70
+ : process.platform === "darwin"
71
+ ? ["open", [url]]
72
+ : ["xdg-open", [url]];
73
+ const child = spawn(command, args, { detached: true, stdio: "ignore", windowsHide: true });
74
+ child.unref();
75
+ }
76
+
77
+ function safeHttpsUrl(value, requiredOrigin) {
78
+ let url;
79
+ try {
80
+ url = new URL(value);
81
+ } catch {
82
+ throw new Error("Morit CLI authorization URL is invalid");
83
+ }
84
+ if (
85
+ url.protocol !== "https:"
86
+ || url.origin !== requiredOrigin
87
+ || url.username
88
+ || url.password
89
+ || url.hash
90
+ ) {
91
+ throw new Error("Morit CLI authorization URL is invalid");
92
+ }
93
+ return url.toString();
94
+ }
95
+
96
+ function validateDeviceAuthorization(value, apiUrl) {
97
+ const verification = safeHttpsUrl(value?.verification_uri, apiUrl);
98
+ const complete = safeHttpsUrl(value?.verification_uri_complete, apiUrl);
99
+ if (
100
+ typeof value?.device_code !== "string"
101
+ || !/^[A-Za-z0-9_-]{40,60}$/.test(value.device_code)
102
+ || typeof value?.user_code !== "string"
103
+ || !/^[A-Z0-9]{4}-[A-Z0-9]{4}$/.test(value.user_code)
104
+ || !Number.isInteger(value?.expires_in)
105
+ || value.expires_in < 60
106
+ || value.expires_in > 900
107
+ || !Number.isInteger(value?.interval)
108
+ || value.interval < 2
109
+ || value.interval > 30
110
+ ) {
111
+ throw new Error("Morit CLI authorization response is invalid");
112
+ }
113
+ return {
114
+ ...value,
115
+ verification_uri: verification,
116
+ verification_uri_complete: complete,
117
+ };
118
+ }
119
+
120
+ function validateDeviceToken(value) {
121
+ if (
122
+ typeof value?.access_token !== "string"
123
+ || !/^sk_live_[A-Za-z0-9]{32}$/.test(value.access_token)
124
+ || typeof value?.organization_id !== "string"
125
+ || !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value.organization_id)
126
+ || !Array.isArray(value?.scopes)
127
+ || value.scopes.some((scope) => typeof scope !== "string")
128
+ ) {
129
+ throw new Error("Morit CLI token response is invalid");
130
+ }
131
+ return value;
132
+ }
133
+
134
+ async function authenticatedClient(options) {
135
+ const credential = await (options.loadCredential || loadCredential)();
136
+ if (!credential) throw new Error("Run morit login first");
137
+ return {
138
+ credential,
139
+ client: new MoritCloudClient({
140
+ apiUrl: credential.apiUrl,
141
+ token: credential.token,
142
+ fetchImpl: options.fetchImpl || fetch,
143
+ }),
144
+ };
145
+ }
146
+
147
+ function pluginRoot(parsed, cwd) {
148
+ return resolve(cwd, parsed.positionals[0] || ".");
149
+ }
150
+
151
+ function manifestMetadata(manifest) {
152
+ return {
153
+ icon: manifest.icon ?? null,
154
+ short_description: manifest.short_description || "",
155
+ category: manifest.category || "other",
156
+ keywords: Array.isArray(manifest.keywords) ? manifest.keywords : [],
157
+ developer: manifest.developer || { name: manifest.publisher || "" },
158
+ homepage_url: manifest.homepage_url ?? null,
159
+ privacy_policy_url: manifest.privacy_policy_url ?? null,
160
+ };
161
+ }
162
+
163
+ async function setupPlugin(root, flags) {
164
+ let project;
165
+ try {
166
+ project = await readProjectDirectory(root);
167
+ } catch (error) {
168
+ if (!flags.id || !flags.name || !flags.publisher) {
169
+ throw new Error("An existing valid manifest.json or --id, --name, and --publisher is required");
170
+ }
171
+ await scaffoldProjectDirectory(root, {
172
+ plugin_id: flags.id,
173
+ name: flags.name,
174
+ publisher: flags.publisher,
175
+ description: flags.description || "",
176
+ advanced: true,
177
+ });
178
+ project = await readProjectDirectory(root);
179
+ }
180
+ const current = await readProjectConfig(root);
181
+ const configPath = await writeProjectConfig(root, current || {
182
+ project_id: null,
183
+ organization_id: null,
184
+ revision: 0,
185
+ source_digest: project.digest,
186
+ api_url: DEFAULT_API_URL,
187
+ });
188
+ return { configured: true, config_path: configPath, plugin_id: project.manifest.id, linked: Boolean(current?.project_id) };
189
+ }
190
+
191
+ export async function linkPluginProject(root, client, organizationId) {
192
+ const source = await readProjectDirectory(root);
193
+ const existing = await readProjectConfig(root);
194
+ if (existing?.project_id) throw new Error(`Project is already linked to ${existing.project_id}`);
195
+ const response = await client.createProject({
196
+ plugin_id: source.manifest.id,
197
+ name: source.manifest.name,
198
+ description: source.manifest.description || "",
199
+ publisher: source.manifest.publisher,
200
+ files: source.files,
201
+ metadata: manifestMetadata(source.manifest),
202
+ });
203
+ const project = response.project;
204
+ const remoteFiles = project.files || {};
205
+ if (typeof remoteFiles["manifest.json"] === "string") {
206
+ await writeFile(join(root, "manifest.json"), remoteFiles["manifest.json"], "utf8");
207
+ }
208
+ const refreshed = await readProjectDirectory(root);
209
+ await writeProjectConfig(root, {
210
+ project_id: project.id,
211
+ organization_id: project.organization_id || organizationId,
212
+ revision: project.revision,
213
+ source_digest: refreshed.digest,
214
+ api_url: client.apiUrl,
215
+ });
216
+ return project;
217
+ }
218
+
219
+ export async function pushPluginProject(root, client) {
220
+ const config = await readProjectConfig(root);
221
+ if (!config?.project_id) throw new Error("Run morit plugin add first");
222
+ const source = await readProjectDirectory(root);
223
+ const response = await client.syncProject(config.project_id, { revision: config.revision, files: source.files });
224
+ const project = response.project;
225
+ await writeProjectConfig(root, {
226
+ ...config,
227
+ revision: project.revision,
228
+ source_digest: source.digest,
229
+ api_url: client.apiUrl,
230
+ });
231
+ return project;
232
+ }
233
+
234
+ export async function pullPluginProject(root, client, force = false) {
235
+ const config = await readProjectConfig(root);
236
+ if (!config?.project_id) throw new Error("Run morit plugin add first");
237
+ const current = await readProjectDirectory(root);
238
+ if (!force && config.source_digest && current.digest !== config.source_digest) {
239
+ throw new Error("Local source changed since the last sync; push it or use --force to replace it");
240
+ }
241
+ const { project } = await client.project(config.project_id);
242
+ const files = project.files || {};
243
+ for (const [name, content] of Object.entries(files)) {
244
+ const safeName = sdkInternal.safeFilePath(name);
245
+ const target = join(root, ...safeName.split("/"));
246
+ await mkdir(dirname(target), { recursive: true });
247
+ await writeFile(target, sdkInternal.projectFileBytes(safeName, content));
248
+ }
249
+ for (const name of Object.keys(current.files)) {
250
+ if (!(name in files)) await unlink(join(root, ...name.split("/"))).catch(() => undefined);
251
+ }
252
+ const refreshed = await readProjectDirectory(root);
253
+ await writeProjectConfig(root, {
254
+ ...config,
255
+ revision: project.revision,
256
+ source_digest: refreshed.digest,
257
+ api_url: client.apiUrl,
258
+ });
259
+ return project;
260
+ }
261
+
262
+ async function login(parsed, options, stdout) {
263
+ const apiUrl = parsed.flags["api-url"] || DEFAULT_API_URL;
264
+ const fetchImpl = options.fetchImpl || fetch;
265
+ const directToken = parsed.flags.token || process.env.MORIT_ACCESS_TOKEN?.trim();
266
+ if (directToken) {
267
+ const client = new MoritCloudClient({ apiUrl, token: directToken, fetchImpl });
268
+ const identity = await client.whoami();
269
+ await (options.saveCredential || saveCredential)({ token: directToken, apiUrl: client.apiUrl, organizationId: identity.organization_id });
270
+ output(stdout, parsed.flags.json ? identity : `Signed in to organization ${identity.organization_id}`, parsed.flags.json);
271
+ return 0;
272
+ }
273
+ const client = new MoritCloudClient({ apiUrl, fetchImpl });
274
+ const authorization = validateDeviceAuthorization(
275
+ await client.request("/api/v1/cli/device/start", {
276
+ method: "POST",
277
+ body: { scopes: ["plugin:read", "plugin:write", "plugin:build", "plugin:deploy"] },
278
+ authenticated: false,
279
+ }),
280
+ client.apiUrl,
281
+ );
282
+ stdout.write(`Open ${authorization.verification_uri}\nEnter code: ${authorization.user_code}\n`);
283
+ (options.openBrowser || openBrowser)(authorization.verification_uri_complete);
284
+ const deadline = Date.now() + authorization.expires_in * 1000;
285
+ while (Date.now() < deadline) {
286
+ await (options.sleep || ((milliseconds) => new Promise((resolveSleep) => setTimeout(resolveSleep, milliseconds))))(authorization.interval * 1000);
287
+ try {
288
+ const token = validateDeviceToken(
289
+ await client.request("/api/v1/cli/device/token", {
290
+ method: "POST",
291
+ body: { device_code: authorization.device_code },
292
+ authenticated: false,
293
+ retries: 0,
294
+ }),
295
+ );
296
+ await (options.saveCredential || saveCredential)({
297
+ token: token.access_token,
298
+ apiUrl: client.apiUrl,
299
+ organizationId: token.organization_id,
300
+ });
301
+ output(stdout, parsed.flags.json ? { organization_id: token.organization_id, scopes: token.scopes } : "Morit CLI is connected", parsed.flags.json);
302
+ return 0;
303
+ } catch (error) {
304
+ if (error instanceof MoritCloudError && error.code === "authorization_pending") continue;
305
+ throw error;
306
+ }
307
+ }
308
+ throw new Error("CLI authorization expired; run morit login again");
309
+ }
310
+
311
+ export async function runCli(argv, options = {}) {
312
+ const stdout = options.stdout || process.stdout;
313
+ const cwd = resolve(options.cwd || process.cwd());
314
+ if (!argv.length || argv.includes("--help") || argv.includes("-h")) {
315
+ stdout.write(HELP);
316
+ return 0;
317
+ }
318
+ const [area, action, ...rest] = argv;
319
+ if (area === "login") return login(parseArguments(argv.slice(1)), options, stdout);
320
+ if (area === "logout") {
321
+ await (options.clearCredential || clearCredential)();
322
+ output(stdout, "Signed out of Morit CLI");
323
+ return 0;
324
+ }
325
+ const parsed = parseArguments(rest);
326
+ const json = Boolean(parsed.flags.json);
327
+
328
+ if (area === "plugin") {
329
+ const root = pluginRoot(parsed, cwd);
330
+ if (action === "setup") {
331
+ output(stdout, await setupPlugin(root, parsed.flags), json);
332
+ return 0;
333
+ }
334
+ if (action === "validate") {
335
+ output(stdout, await validateProjectDirectory(root), json);
336
+ return 0;
337
+ }
338
+ if (action === "build") {
339
+ output(stdout, await buildProjectDirectory(root, {
340
+ output: parsed.flags.output ? resolve(cwd, parsed.flags.output) : undefined,
341
+ }), json);
342
+ return 0;
343
+ }
344
+ const { client, credential } = await authenticatedClient(options);
345
+ if (action === "add") {
346
+ output(stdout, await linkPluginProject(root, client, credential.organizationId), json);
347
+ return 0;
348
+ }
349
+ if (action === "sync") {
350
+ output(stdout, parsed.flags.pull
351
+ ? await pullPluginProject(root, client, Boolean(parsed.flags.force))
352
+ : await pushPluginProject(root, client), json);
353
+ return 0;
354
+ }
355
+ if (action === "deploy") {
356
+ const project = await pushPluginProject(root, client);
357
+ const visibility = parsed.flags.visibility || "private";
358
+ if (!["private", "public"].includes(visibility)) throw new Error("--visibility must be private or public");
359
+ const artifact = await buildProjectDirectory(root, {});
360
+ const content = await readFile(artifact.output_path);
361
+ const response = await client.deploy(project.id, {
362
+ channel: "local_cli",
363
+ visibility,
364
+ file_name: parsed.flags["file-name"] || basename(artifact.output_path),
365
+ artifact: {
366
+ encoding: "base64",
367
+ content: content.toString("base64"),
368
+ size_bytes: artifact.size_bytes,
369
+ sha256: artifact.sha256,
370
+ },
371
+ });
372
+ output(stdout, { ...response.deployment, local_artifact_path: artifact.output_path }, json);
373
+ return 0;
374
+ }
375
+ throw new Error(`Unknown plugin command: ${action || "missing"}`);
376
+ }
377
+
378
+ const { client } = await authenticatedClient(options);
379
+ if (area === "project") {
380
+ if (action === "list") output(stdout, (await client.projects()).projects, json);
381
+ else if (action === "get" && parsed.positionals[0]) output(stdout, (await client.project(parsed.positionals[0])).project, json);
382
+ else if (action === "delete" && parsed.positionals[0]) output(stdout, await client.deleteProject(parsed.positionals[0]), json);
383
+ else throw new Error("Use morit project list|get <id>|delete <id>");
384
+ return 0;
385
+ }
386
+ if (area === "deployment") {
387
+ if (action === "list") {
388
+ let projectId = parsed.positionals[0];
389
+ if (!projectId) projectId = (await readProjectConfig(cwd))?.project_id;
390
+ if (!projectId) throw new Error("Provide a project id or run inside a linked Plugin Project");
391
+ output(stdout, (await client.deployments(projectId)).deployments, json);
392
+ } else if (action === "get" && parsed.positionals[0]) {
393
+ output(stdout, (await client.deployment(parsed.positionals[0])).deployment, json);
394
+ } else if (action === "publish" && parsed.positionals[0]) {
395
+ const visibility = parsed.flags.visibility;
396
+ if (!["private", "public"].includes(visibility)) throw new Error("--visibility must be private or public");
397
+ output(stdout, (await client.publish(parsed.positionals[0], visibility)).deployment, json);
398
+ } else {
399
+ throw new Error("Use morit deployment list|get <id>|publish <id> --visibility private|public");
400
+ }
401
+ return 0;
402
+ }
403
+ throw new Error(`Unknown command: ${area}`);
404
+ }
405
+
406
+ export const internal = {
407
+ HELP,
408
+ linkPluginProject,
409
+ login,
410
+ parseArguments,
411
+ pullPluginProject,
412
+ pushPluginProject,
413
+ setupPlugin,
414
+ };
@@ -0,0 +1,89 @@
1
+ import { normalizeApiOrigin } from "./config.js";
2
+
3
+ const RETRYABLE = new Set([502, 503, 504]);
4
+
5
+ export class MoritCloudError extends Error {
6
+ constructor(message, { status = 0, code = "cloud_error" } = {}) {
7
+ super(message);
8
+ this.status = status;
9
+ this.code = code;
10
+ }
11
+ }
12
+
13
+ export class MoritCloudClient {
14
+ constructor({ apiUrl = "https://developers.moring.co", token = null, fetchImpl = fetch } = {}) {
15
+ this.apiUrl = normalizeApiOrigin(apiUrl);
16
+ if (token !== null && (
17
+ typeof token !== "string"
18
+ || !token
19
+ || token.length > 256
20
+ || /[\r\n]/.test(token)
21
+ )) {
22
+ throw new MoritCloudError("Morit access token is invalid", {
23
+ status: 401,
24
+ code: "invalid_token",
25
+ });
26
+ }
27
+ this.token = token;
28
+ this.fetchImpl = fetchImpl;
29
+ }
30
+
31
+ async request(path, { method = "GET", body, authenticated = true, retries = 2 } = {}) {
32
+ const headers = { Accept: "application/json" };
33
+ if (body !== undefined) headers["Content-Type"] = "application/json";
34
+ if (authenticated) {
35
+ if (!this.token) throw new MoritCloudError("Run morit login first", { status: 401, code: "not_authenticated" });
36
+ headers.Authorization = `Bearer ${this.token}`;
37
+ }
38
+ for (let attempt = 0; ; attempt += 1) {
39
+ let response;
40
+ try {
41
+ response = await this.fetchImpl(new URL(path, this.apiUrl), {
42
+ method,
43
+ headers,
44
+ body: body === undefined ? undefined : JSON.stringify(body),
45
+ signal: AbortSignal.timeout(30_000),
46
+ });
47
+ } catch (error) {
48
+ if (attempt < retries && method === "GET") {
49
+ await new Promise((resolve) => setTimeout(resolve, 300 * (2 ** attempt)));
50
+ continue;
51
+ }
52
+ throw new MoritCloudError("Morit Cloud is unreachable; check the network and retry", { code: "network_error" });
53
+ }
54
+ const value = await response.json().catch(() => ({}));
55
+ if (response.ok) return value;
56
+ if (attempt < retries && method === "GET" && RETRYABLE.has(response.status)) {
57
+ await new Promise((resolve) => setTimeout(resolve, 300 * (2 ** attempt)));
58
+ continue;
59
+ }
60
+ const error = value?.error;
61
+ const message = typeof error === "string" ? error : error?.message;
62
+ if (response.status === 401) {
63
+ throw new MoritCloudError("Developer session expired; run morit login again", { status: 401, code: error?.code || "unauthorized" });
64
+ }
65
+ throw new MoritCloudError(message || `Morit Cloud returned HTTP ${response.status}`, {
66
+ status: response.status,
67
+ code: error?.code || "cloud_error",
68
+ });
69
+ }
70
+ }
71
+
72
+ projects() { return this.request("/api/v1/plugin-projects"); }
73
+ whoami() { return this.request("/api/v1/cli/whoami"); }
74
+ project(id) { return this.request(`/api/v1/plugin-projects/${encodeURIComponent(id)}`); }
75
+ createProject(input) { return this.request("/api/v1/plugin-projects", { method: "POST", body: input }); }
76
+ updateProject(id, input) { return this.request(`/api/v1/plugin-projects/${encodeURIComponent(id)}`, { method: "PATCH", body: input }); }
77
+ syncProject(id, input) { return this.request(`/api/v1/plugin-projects/${encodeURIComponent(id)}/source`, { method: "PUT", body: input }); }
78
+ deleteProject(id) { return this.request(`/api/v1/plugin-projects/${encodeURIComponent(id)}`, { method: "DELETE" }); }
79
+ deploy(id, input) { return this.request(`/api/v1/plugin-projects/${encodeURIComponent(id)}/deployments`, { method: "POST", body: input }); }
80
+ deployments(id) { return this.request(`/api/v1/plugin-projects/${encodeURIComponent(id)}/deployments`); }
81
+ deployment(id) { return this.request(`/api/v1/plugin-deployments/${encodeURIComponent(id)}`); }
82
+ publish(id, visibility) { return this.request(`/api/v1/plugin-deployments/${encodeURIComponent(id)}`, { method: "PATCH", body: { visibility } }); }
83
+ secrets(id) { return this.request(`/api/v1/plugin-projects/${encodeURIComponent(id)}/secrets`); }
84
+ putSecret(id, input) { return this.request(`/api/v1/plugin-projects/${encodeURIComponent(id)}/secrets`, { method: "PUT", body: input }); }
85
+ deleteSecret(id, name) { return this.request(`/api/v1/plugin-projects/${encodeURIComponent(id)}/secrets?name=${encodeURIComponent(name)}`, { method: "DELETE" }); }
86
+ connections(id) { return this.request(`/api/v1/plugin-projects/${encodeURIComponent(id)}/connections`); }
87
+ putConnection(id, input) { return this.request(`/api/v1/plugin-projects/${encodeURIComponent(id)}/connections`, { method: "PUT", body: input }); }
88
+ deleteConnection(id, connectionId) { return this.request(`/api/v1/plugin-projects/${encodeURIComponent(id)}/connections?connection_id=${encodeURIComponent(connectionId)}`, { method: "DELETE" }); }
89
+ }
package/src/config.js ADDED
@@ -0,0 +1,82 @@
1
+ import { createHash } from "node:crypto";
2
+ import { chmod, mkdir, readFile, rename, writeFile } from "node:fs/promises";
3
+ import { dirname, join, resolve } from "node:path";
4
+
5
+ export const PROJECT_CONFIG = "morit-plugin.json";
6
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
7
+
8
+ export function normalizeApiOrigin(value) {
9
+ let url;
10
+ try {
11
+ url = new URL(value);
12
+ } catch {
13
+ throw new Error("Morit API URL must be an HTTPS origin");
14
+ }
15
+ if (
16
+ url.protocol !== "https:"
17
+ || !url.hostname
18
+ || url.username
19
+ || url.password
20
+ || (url.pathname !== "/" && url.pathname !== "")
21
+ || url.search
22
+ || url.hash
23
+ || (url.port && url.port !== "443")
24
+ ) {
25
+ throw new Error("Morit API URL must be an HTTPS origin");
26
+ }
27
+ return url.origin;
28
+ }
29
+
30
+ export async function readProjectConfig(root) {
31
+ const path = join(resolve(root), PROJECT_CONFIG);
32
+ let value;
33
+ try {
34
+ value = JSON.parse(await readFile(path, "utf8"));
35
+ } catch (error) {
36
+ if (error.code === "ENOENT") return null;
37
+ throw new Error("morit-plugin.json is not valid JSON");
38
+ }
39
+ if (
40
+ value?.version !== 1
41
+ || (value.project_id !== null && !UUID.test(value.project_id))
42
+ || (value.organization_id !== null && !UUID.test(value.organization_id))
43
+ || !Number.isInteger(value.revision)
44
+ || value.revision < 0
45
+ || (value.source_digest !== null && value.source_digest !== undefined
46
+ && !/^[a-f0-9]{64}$/.test(value.source_digest))
47
+ ) {
48
+ throw new Error("morit-plugin.json has an unsupported format");
49
+ }
50
+ if (value.api_url !== undefined) value.api_url = normalizeApiOrigin(value.api_url);
51
+ return value;
52
+ }
53
+
54
+ export async function writeProjectConfig(root, value) {
55
+ const directory = resolve(root);
56
+ const path = join(directory, PROJECT_CONFIG);
57
+ await mkdir(directory, { recursive: true });
58
+ const temporary = `${path}.tmp`;
59
+ const normalized = {
60
+ version: 1,
61
+ ...value,
62
+ api_url: normalizeApiOrigin(value.api_url),
63
+ };
64
+ await writeFile(temporary, `${JSON.stringify(normalized, null, 2)}\n`, {
65
+ encoding: "utf8",
66
+ mode: 0o600,
67
+ });
68
+ await rename(temporary, path);
69
+ await chmod(path, 0o600).catch(() => undefined);
70
+ return path;
71
+ }
72
+
73
+ export function filesDigest(files) {
74
+ const digest = createHash("sha256");
75
+ for (const name of Object.keys(files).sort()) {
76
+ digest.update(name);
77
+ digest.update("\0");
78
+ digest.update(files[name]);
79
+ digest.update("\0");
80
+ }
81
+ return digest.digest("hex");
82
+ }