@morit/cli 1.1.0 → 1.1.1
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/assets/docs/project-structure.md +12 -0
- package/package.json +1 -1
- package/src/cli.js +27 -3
- package/src/secure-store.js +113 -43
|
@@ -74,6 +74,18 @@ UI에서 참조하는 package 이미지는 최대 512 KiB이고 가로·세로
|
|
|
74
74
|
계산한 frame 합산 pixel은 최대 16,000,000입니다. 확장자·MIME에 대응하는 magic byte와 실제 이미지
|
|
75
75
|
포맷도 일치해야 합니다.
|
|
76
76
|
|
|
77
|
+
### 웹 프로젝트에서 아이콘 올리기
|
|
78
|
+
|
|
79
|
+
developers.moring.co의 **플러그인 프로젝트 관리 → 정보 → 앱 아이콘**에서는 PNG, JPEG, WebP
|
|
80
|
+
파일을 직접 선택하거나 끌어 놓을 수 있습니다. 업로드가 완료되면 이미지는
|
|
81
|
+
`assets/plugin-icon.*`에 저장되고 `manifest.icon`도 같은 revision에서 함께 갱신됩니다. 기존에 이
|
|
82
|
+
화면에서 올린 아이콘을 교체하거나 제거할 때도 파일과 manifest가 원자적으로 함께 반영됩니다.
|
|
83
|
+
|
|
84
|
+
아이콘은 512 KiB 이하, 가로·세로 각각 4096 px 이하, 전체 16,000,000 pixel 이하만 허용됩니다.
|
|
85
|
+
확장자만 바꾼 파일이나 실제 포맷이 다른 이미지는 서버 검증에서 거부됩니다. MCP가 바이너리 파일을
|
|
86
|
+
직접 전송하지 못하더라도 이 화면에서 한 번 올린 아이콘은 프로젝트 source에 포함되므로 이후 MCP,
|
|
87
|
+
CLI pull·build에서도 원래 이미지 바이트와 `manifest.icon` 경로가 그대로 유지됩니다.
|
|
88
|
+
|
|
77
89
|
## 파일별 책임
|
|
78
90
|
|
|
79
91
|
- `manifest.json`: 패키지 신원, 버전, 전역 권한, 연결과 데이터 정책
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -259,6 +259,15 @@ export async function pullPluginProject(root, client, force = false) {
|
|
|
259
259
|
return project;
|
|
260
260
|
}
|
|
261
261
|
|
|
262
|
+
async function saveLoginCredential(save, credential) {
|
|
263
|
+
try {
|
|
264
|
+
await save(credential);
|
|
265
|
+
} catch (error) {
|
|
266
|
+
const reason = error instanceof Error ? error.message : "secure credential storage failed";
|
|
267
|
+
throw new Error(`Authorization succeeded, but the token could not be stored safely: ${reason}. Run morit logout to recover, then login again`);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
262
271
|
async function login(parsed, options, stdout) {
|
|
263
272
|
const apiUrl = parsed.flags["api-url"] || DEFAULT_API_URL;
|
|
264
273
|
const fetchImpl = options.fetchImpl || fetch;
|
|
@@ -266,7 +275,11 @@ async function login(parsed, options, stdout) {
|
|
|
266
275
|
if (directToken) {
|
|
267
276
|
const client = new MoritCloudClient({ apiUrl, token: directToken, fetchImpl });
|
|
268
277
|
const identity = await client.whoami();
|
|
269
|
-
await (options.saveCredential || saveCredential
|
|
278
|
+
await saveLoginCredential(options.saveCredential || saveCredential, {
|
|
279
|
+
token: directToken,
|
|
280
|
+
apiUrl: client.apiUrl,
|
|
281
|
+
organizationId: identity.organization_id,
|
|
282
|
+
});
|
|
270
283
|
output(stdout, parsed.flags.json ? identity : `Signed in to organization ${identity.organization_id}`, parsed.flags.json);
|
|
271
284
|
return 0;
|
|
272
285
|
}
|
|
@@ -282,6 +295,7 @@ async function login(parsed, options, stdout) {
|
|
|
282
295
|
stdout.write(`Open ${authorization.verification_uri}\nEnter code: ${authorization.user_code}\n`);
|
|
283
296
|
(options.openBrowser || openBrowser)(authorization.verification_uri_complete);
|
|
284
297
|
const deadline = Date.now() + authorization.expires_in * 1000;
|
|
298
|
+
let networkFailures = 0;
|
|
285
299
|
while (Date.now() < deadline) {
|
|
286
300
|
await (options.sleep || ((milliseconds) => new Promise((resolveSleep) => setTimeout(resolveSleep, milliseconds))))(authorization.interval * 1000);
|
|
287
301
|
try {
|
|
@@ -293,7 +307,7 @@ async function login(parsed, options, stdout) {
|
|
|
293
307
|
retries: 0,
|
|
294
308
|
}),
|
|
295
309
|
);
|
|
296
|
-
await (options.saveCredential || saveCredential
|
|
310
|
+
await saveLoginCredential(options.saveCredential || saveCredential, {
|
|
297
311
|
token: token.access_token,
|
|
298
312
|
apiUrl: client.apiUrl,
|
|
299
313
|
organizationId: token.organization_id,
|
|
@@ -301,7 +315,17 @@ async function login(parsed, options, stdout) {
|
|
|
301
315
|
output(stdout, parsed.flags.json ? { organization_id: token.organization_id, scopes: token.scopes } : "Morit CLI is connected", parsed.flags.json);
|
|
302
316
|
return 0;
|
|
303
317
|
} catch (error) {
|
|
304
|
-
if (error instanceof MoritCloudError && error.code === "authorization_pending")
|
|
318
|
+
if (error instanceof MoritCloudError && error.code === "authorization_pending") {
|
|
319
|
+
networkFailures = 0;
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
if (error instanceof MoritCloudError && error.code === "network_error" && ++networkFailures < 3) continue;
|
|
323
|
+
if (error instanceof MoritCloudError && error.code === "access_denied") {
|
|
324
|
+
throw new Error("CLI authorization was cancelled");
|
|
325
|
+
}
|
|
326
|
+
if (error instanceof MoritCloudError && ["expired_token", "device_code_expired"].includes(error.code)) {
|
|
327
|
+
throw new Error("CLI authorization expired; run morit login again");
|
|
328
|
+
}
|
|
305
329
|
throw error;
|
|
306
330
|
}
|
|
307
331
|
}
|
package/src/secure-store.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
2
|
import { chmod, mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
|
|
3
3
|
import { homedir, userInfo } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
@@ -8,14 +8,20 @@ const credentialDirectory = join(homedir(), ".morit");
|
|
|
8
8
|
const credentialPath = join(credentialDirectory, "credentials.json");
|
|
9
9
|
const serviceName = "morit-cli";
|
|
10
10
|
const ACCESS_TOKEN = /^sk_live_[A-Za-z0-9]{32}$/;
|
|
11
|
+
const STORAGE_BY_PLATFORM = { win32: "dpapi", darwin: "keychain", linux: "secret-tool" };
|
|
11
12
|
|
|
12
13
|
function powershell(script, input = "") {
|
|
13
14
|
const result = spawnSync(
|
|
14
15
|
"powershell.exe",
|
|
15
|
-
["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", script],
|
|
16
|
+
["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", `$ErrorActionPreference='Stop';Add-Type -AssemblyName System.Security;${script}`],
|
|
16
17
|
{ input, encoding: "utf8", windowsHide: true, maxBuffer: 1024 * 1024 },
|
|
17
18
|
);
|
|
18
|
-
if (result.
|
|
19
|
+
if (result.error?.code === "ENOENT") {
|
|
20
|
+
throw new Error("Windows PowerShell is required to protect Morit credentials");
|
|
21
|
+
}
|
|
22
|
+
if (result.status !== 0 || !result.stdout.trim()) {
|
|
23
|
+
throw new Error("Windows credential protection failed; verify that Windows PowerShell and DPAPI are available");
|
|
24
|
+
}
|
|
19
25
|
return result.stdout.trim();
|
|
20
26
|
}
|
|
21
27
|
|
|
@@ -38,55 +44,115 @@ function windowsUnprotect(payload) {
|
|
|
38
44
|
}
|
|
39
45
|
|
|
40
46
|
function linuxSecretToolAvailable() {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
47
|
+
const result = spawnSync("secret-tool", ["--help"], { stdio: "ignore" });
|
|
48
|
+
return !result.error && result.status === 0;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function nativeCredential(storage, action, account, token) {
|
|
52
|
+
let command;
|
|
53
|
+
let args;
|
|
54
|
+
if (storage === "keychain") {
|
|
55
|
+
command = "security";
|
|
56
|
+
args = action === "store"
|
|
57
|
+
? ["add-generic-password", "-a", account, "-s", serviceName, "-w", token, "-U"]
|
|
58
|
+
: action === "read"
|
|
59
|
+
? ["find-generic-password", "-a", account, "-s", serviceName, "-w"]
|
|
60
|
+
: ["delete-generic-password", "-a", account, "-s", serviceName];
|
|
61
|
+
} else {
|
|
62
|
+
if (!linuxSecretToolAvailable()) {
|
|
63
|
+
throw new Error("Linux Secret Service is unavailable; install secret-tool and unlock a desktop keyring, or use MORIT_ACCESS_TOKEN for this session");
|
|
64
|
+
}
|
|
65
|
+
command = "secret-tool";
|
|
66
|
+
args = action === "store"
|
|
67
|
+
? ["store", "--label=Morit CLI", "service", serviceName, "account", account]
|
|
68
|
+
: action === "read"
|
|
69
|
+
? ["lookup", "service", serviceName, "account", account]
|
|
70
|
+
: ["clear", "service", serviceName, "account", account];
|
|
46
71
|
}
|
|
72
|
+
const result = spawnSync(command, args, {
|
|
73
|
+
input: action === "store" && storage === "secret-tool" ? token : undefined,
|
|
74
|
+
encoding: "utf8",
|
|
75
|
+
windowsHide: true,
|
|
76
|
+
maxBuffer: 1024 * 1024,
|
|
77
|
+
});
|
|
78
|
+
const label = storage === "keychain" ? "macOS Keychain" : "Linux Secret Service";
|
|
79
|
+
const stdout = result.stdout?.trim() || "";
|
|
80
|
+
const stderr = result.stderr?.trim() || "";
|
|
81
|
+
const missing = action === "delete" && (
|
|
82
|
+
(storage === "keychain" && result.status === 44)
|
|
83
|
+
|| (storage === "secret-tool" && result.status === 1 && !stderr)
|
|
84
|
+
);
|
|
85
|
+
if (result.error?.code === "ENOENT") throw new Error(`${label} command is unavailable`);
|
|
86
|
+
if (result.status !== 0 && !missing) {
|
|
87
|
+
throw new Error(`Unable to ${action} the Morit token in ${label}; unlock the secure store and retry`);
|
|
88
|
+
}
|
|
89
|
+
if (action === "read" && !stdout) {
|
|
90
|
+
throw new Error(`The saved Morit token is missing from ${label}; run morit logout and login again`);
|
|
91
|
+
}
|
|
92
|
+
return stdout;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function validMetadata(value) {
|
|
96
|
+
if (!value || typeof value !== "object" || value.version !== 1 || typeof value.api_url !== "string") return false;
|
|
97
|
+
if (value.storage === "dpapi") return typeof value.payload === "string" && value.payload.length > 0;
|
|
98
|
+
return (value.storage === "keychain" || value.storage === "secret-tool")
|
|
99
|
+
&& typeof value.account === "string" && value.account.length > 0 && value.account.length <= 256;
|
|
47
100
|
}
|
|
48
101
|
|
|
49
|
-
async function metadata() {
|
|
50
|
-
try {
|
|
51
|
-
|
|
102
|
+
async function metadata({ allowCorrupt = false } = {}) {
|
|
103
|
+
try {
|
|
104
|
+
const value = JSON.parse(await readFile(credentialPath, "utf8"));
|
|
105
|
+
if (!validMetadata(value)) throw new Error("invalid metadata");
|
|
106
|
+
return value;
|
|
107
|
+
} catch (error) {
|
|
52
108
|
if (error.code === "ENOENT") return null;
|
|
109
|
+
if (allowCorrupt) return { corrupt: true };
|
|
53
110
|
throw new Error("Morit credential metadata is unreadable; run morit logout and login again");
|
|
54
111
|
}
|
|
55
112
|
}
|
|
56
113
|
|
|
57
114
|
async function writeMetadata(value) {
|
|
58
115
|
await mkdir(credentialDirectory, { recursive: true, mode: 0o700 });
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
116
|
+
await chmod(credentialDirectory, 0o700).catch(() => undefined);
|
|
117
|
+
const temporary = `${credentialPath}.${process.pid}.tmp`;
|
|
118
|
+
try {
|
|
119
|
+
await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
120
|
+
await rename(temporary, credentialPath);
|
|
121
|
+
await chmod(credentialPath, 0o600).catch(() => undefined);
|
|
122
|
+
} catch (error) {
|
|
123
|
+
await unlink(temporary).catch(() => undefined);
|
|
124
|
+
throw new Error("Unable to update Morit credential metadata", { cause: error });
|
|
125
|
+
}
|
|
63
126
|
}
|
|
64
127
|
|
|
65
128
|
export async function saveCredential({ token, apiUrl, organizationId }) {
|
|
66
129
|
if (!ACCESS_TOKEN.test(token || "")) throw new Error("Developer access token is invalid");
|
|
67
130
|
const normalizedApiUrl = normalizeApiOrigin(apiUrl);
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
131
|
+
const previous = await metadata();
|
|
132
|
+
const storage = STORAGE_BY_PLATFORM[process.platform];
|
|
133
|
+
if (!storage) throw new Error(`Secure credential storage is not supported on ${process.platform}`);
|
|
134
|
+
if (previous && previous.storage !== storage) {
|
|
135
|
+
throw new Error("Saved credentials belong to a different platform; run morit logout before logging in again");
|
|
72
136
|
}
|
|
73
|
-
if (
|
|
74
|
-
|
|
75
|
-
stdio: "ignore",
|
|
76
|
-
});
|
|
77
|
-
if (result.status !== 0) throw new Error("Unable to save token in macOS Keychain");
|
|
78
|
-
await writeMetadata({ version: 1, storage: "keychain", account, api_url: normalizedApiUrl, organization_id: organizationId });
|
|
137
|
+
if (storage === "dpapi") {
|
|
138
|
+
await writeMetadata({ version: 1, storage, payload: windowsProtect(token), api_url: normalizedApiUrl, organization_id: organizationId });
|
|
79
139
|
return;
|
|
80
140
|
}
|
|
81
|
-
|
|
82
|
-
|
|
141
|
+
|
|
142
|
+
const account = userInfo().username;
|
|
143
|
+
const previousToken = previous ? nativeCredential(storage, "read", previous.account) : null;
|
|
144
|
+
nativeCredential(storage, "store", account, token);
|
|
145
|
+
try {
|
|
146
|
+
await writeMetadata({ version: 1, storage, account, api_url: normalizedApiUrl, organization_id: organizationId });
|
|
147
|
+
} catch (error) {
|
|
148
|
+
try {
|
|
149
|
+
if (previousToken) nativeCredential(storage, "store", previous.account, previousToken);
|
|
150
|
+
else nativeCredential(storage, "delete", account);
|
|
151
|
+
} catch {
|
|
152
|
+
throw new Error("Credential metadata failed and secure-store recovery also failed; run morit logout before retrying");
|
|
153
|
+
}
|
|
154
|
+
throw error;
|
|
83
155
|
}
|
|
84
|
-
const result = spawnSync("secret-tool", ["store", "--label=Morit CLI", "service", serviceName, "account", account], {
|
|
85
|
-
input: token,
|
|
86
|
-
encoding: "utf8",
|
|
87
|
-
});
|
|
88
|
-
if (result.status !== 0) throw new Error("Unable to save token in the desktop keyring");
|
|
89
|
-
await writeMetadata({ version: 1, storage: "secret-tool", account, api_url: normalizedApiUrl, organization_id: organizationId });
|
|
90
156
|
}
|
|
91
157
|
|
|
92
158
|
export async function loadCredential() {
|
|
@@ -107,24 +173,28 @@ export async function loadCredential() {
|
|
|
107
173
|
if (value.storage === "dpapi" && process.platform === "win32") {
|
|
108
174
|
token = windowsUnprotect(value.payload);
|
|
109
175
|
} else if (value.storage === "keychain" && process.platform === "darwin") {
|
|
110
|
-
token =
|
|
111
|
-
} else if (value.storage === "secret-tool" &&
|
|
112
|
-
token =
|
|
176
|
+
token = nativeCredential(value.storage, "read", value.account);
|
|
177
|
+
} else if (value.storage === "secret-tool" && process.platform === "linux") {
|
|
178
|
+
token = nativeCredential(value.storage, "read", value.account);
|
|
113
179
|
} else {
|
|
114
180
|
throw new Error("Saved credentials belong to a different platform; run morit logout and login again");
|
|
115
181
|
}
|
|
116
|
-
if (!ACCESS_TOKEN.test(token)) throw new Error("Saved Morit credential is invalid");
|
|
182
|
+
if (!ACCESS_TOKEN.test(token)) throw new Error("Saved Morit credential is invalid; run morit logout and login again");
|
|
117
183
|
return { token, apiUrl, organizationId: value.organization_id, ephemeral: false };
|
|
118
184
|
}
|
|
119
185
|
|
|
120
186
|
export async function clearCredential() {
|
|
121
|
-
const value = await metadata();
|
|
122
|
-
if (value
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
187
|
+
const value = await metadata({ allowCorrupt: true });
|
|
188
|
+
if (!value) return;
|
|
189
|
+
if (value.corrupt) {
|
|
190
|
+
if (process.platform === "darwin") nativeCredential("keychain", "delete", userInfo().username);
|
|
191
|
+
else if (process.platform === "linux") nativeCredential("secret-tool", "delete", userInfo().username);
|
|
192
|
+
} else if (value.storage === "keychain" && process.platform === "darwin") {
|
|
193
|
+
nativeCredential(value.storage, "delete", value.account);
|
|
194
|
+
} else if (value.storage === "secret-tool" && process.platform === "linux") {
|
|
195
|
+
nativeCredential(value.storage, "delete", value.account);
|
|
126
196
|
}
|
|
127
197
|
await unlink(credentialPath).catch((error) => {
|
|
128
|
-
if (error.code !== "ENOENT") throw error;
|
|
198
|
+
if (error.code !== "ENOENT") throw new Error("Unable to remove Morit credential metadata", { cause: error });
|
|
129
199
|
});
|
|
130
200
|
}
|