@morit/cli 1.1.0 → 1.2.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/README.md +5 -3
- package/assets/docs/README.md +18 -16
- package/assets/docs/ai-response-and-timeline.md +25 -0
- package/assets/docs/components.md +48 -1
- package/assets/docs/design-tokens-responsive.md +33 -10
- package/assets/docs/docs-index.json +1 -0
- package/assets/docs/examples-school-life.md +12 -7
- package/assets/docs/getting-started.md +1 -1
- package/assets/docs/lifecycle-and-api.md +11 -0
- package/assets/docs/local-cli.md +4 -3
- package/assets/docs/manifest.md +23 -1
- package/assets/docs/packaging-and-testing.md +8 -2
- package/assets/docs/permissions-and-data.md +23 -5
- package/assets/docs/plugin-storage.md +175 -0
- package/assets/docs/project-structure.md +12 -0
- package/assets/docs/remote-mcp.md +9 -3
- package/assets/docs/school-life-privacy.md +7 -1
- package/assets/docs/sdk-and-mcp.md +13 -2
- package/assets/docs/tool-and-skill.md +10 -1
- package/assets/docs/ui-runtime-v2.md +67 -0
- package/assets/docs/verification.md +4 -4
- package/assets/plugin_contract.json +71 -4
- package/package.json +1 -1
- package/src/cli.js +36 -3
- package/src/preview.js +132 -0
- package/src/secure-store.js +113 -43
- package/src/workspace.js +170 -16
package/src/cli.js
CHANGED
|
@@ -7,6 +7,7 @@ import { clearCredential, loadCredential, saveCredential } from "./secure-store.
|
|
|
7
7
|
import {
|
|
8
8
|
buildProjectDirectory,
|
|
9
9
|
internal as sdkInternal,
|
|
10
|
+
previewProjectDirectory,
|
|
10
11
|
readProjectDirectory,
|
|
11
12
|
scaffoldProjectDirectory,
|
|
12
13
|
validateProjectDirectory,
|
|
@@ -21,6 +22,7 @@ Usage:
|
|
|
21
22
|
morit plugin setup [directory] --id <plugin.id> --name <name> --publisher <publisher>
|
|
22
23
|
morit plugin add [directory]
|
|
23
24
|
morit plugin validate [directory]
|
|
25
|
+
morit plugin preview [directory] [--output <file.html>]
|
|
24
26
|
morit plugin build [directory] [--output <file.mplg>]
|
|
25
27
|
morit plugin sync [directory] [--pull] [--force]
|
|
26
28
|
morit plugin deploy [directory] [--visibility private|public] [--file-name <file.mplg>]
|
|
@@ -259,6 +261,15 @@ export async function pullPluginProject(root, client, force = false) {
|
|
|
259
261
|
return project;
|
|
260
262
|
}
|
|
261
263
|
|
|
264
|
+
async function saveLoginCredential(save, credential) {
|
|
265
|
+
try {
|
|
266
|
+
await save(credential);
|
|
267
|
+
} catch (error) {
|
|
268
|
+
const reason = error instanceof Error ? error.message : "secure credential storage failed";
|
|
269
|
+
throw new Error(`Authorization succeeded, but the token could not be stored safely: ${reason}. Run morit logout to recover, then login again`);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
262
273
|
async function login(parsed, options, stdout) {
|
|
263
274
|
const apiUrl = parsed.flags["api-url"] || DEFAULT_API_URL;
|
|
264
275
|
const fetchImpl = options.fetchImpl || fetch;
|
|
@@ -266,7 +277,11 @@ async function login(parsed, options, stdout) {
|
|
|
266
277
|
if (directToken) {
|
|
267
278
|
const client = new MoritCloudClient({ apiUrl, token: directToken, fetchImpl });
|
|
268
279
|
const identity = await client.whoami();
|
|
269
|
-
await (options.saveCredential || saveCredential
|
|
280
|
+
await saveLoginCredential(options.saveCredential || saveCredential, {
|
|
281
|
+
token: directToken,
|
|
282
|
+
apiUrl: client.apiUrl,
|
|
283
|
+
organizationId: identity.organization_id,
|
|
284
|
+
});
|
|
270
285
|
output(stdout, parsed.flags.json ? identity : `Signed in to organization ${identity.organization_id}`, parsed.flags.json);
|
|
271
286
|
return 0;
|
|
272
287
|
}
|
|
@@ -282,6 +297,7 @@ async function login(parsed, options, stdout) {
|
|
|
282
297
|
stdout.write(`Open ${authorization.verification_uri}\nEnter code: ${authorization.user_code}\n`);
|
|
283
298
|
(options.openBrowser || openBrowser)(authorization.verification_uri_complete);
|
|
284
299
|
const deadline = Date.now() + authorization.expires_in * 1000;
|
|
300
|
+
let networkFailures = 0;
|
|
285
301
|
while (Date.now() < deadline) {
|
|
286
302
|
await (options.sleep || ((milliseconds) => new Promise((resolveSleep) => setTimeout(resolveSleep, milliseconds))))(authorization.interval * 1000);
|
|
287
303
|
try {
|
|
@@ -293,7 +309,7 @@ async function login(parsed, options, stdout) {
|
|
|
293
309
|
retries: 0,
|
|
294
310
|
}),
|
|
295
311
|
);
|
|
296
|
-
await (options.saveCredential || saveCredential
|
|
312
|
+
await saveLoginCredential(options.saveCredential || saveCredential, {
|
|
297
313
|
token: token.access_token,
|
|
298
314
|
apiUrl: client.apiUrl,
|
|
299
315
|
organizationId: token.organization_id,
|
|
@@ -301,7 +317,17 @@ async function login(parsed, options, stdout) {
|
|
|
301
317
|
output(stdout, parsed.flags.json ? { organization_id: token.organization_id, scopes: token.scopes } : "Morit CLI is connected", parsed.flags.json);
|
|
302
318
|
return 0;
|
|
303
319
|
} catch (error) {
|
|
304
|
-
if (error instanceof MoritCloudError && error.code === "authorization_pending")
|
|
320
|
+
if (error instanceof MoritCloudError && error.code === "authorization_pending") {
|
|
321
|
+
networkFailures = 0;
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
if (error instanceof MoritCloudError && error.code === "network_error" && ++networkFailures < 3) continue;
|
|
325
|
+
if (error instanceof MoritCloudError && error.code === "access_denied") {
|
|
326
|
+
throw new Error("CLI authorization was cancelled");
|
|
327
|
+
}
|
|
328
|
+
if (error instanceof MoritCloudError && ["expired_token", "device_code_expired"].includes(error.code)) {
|
|
329
|
+
throw new Error("CLI authorization expired; run morit login again");
|
|
330
|
+
}
|
|
305
331
|
throw error;
|
|
306
332
|
}
|
|
307
333
|
}
|
|
@@ -335,6 +361,13 @@ export async function runCli(argv, options = {}) {
|
|
|
335
361
|
output(stdout, await validateProjectDirectory(root), json);
|
|
336
362
|
return 0;
|
|
337
363
|
}
|
|
364
|
+
if (action === "preview") {
|
|
365
|
+
output(stdout, await previewProjectDirectory(
|
|
366
|
+
root,
|
|
367
|
+
parsed.flags.output ? resolve(cwd, parsed.flags.output) : undefined,
|
|
368
|
+
), json);
|
|
369
|
+
return 0;
|
|
370
|
+
}
|
|
338
371
|
if (action === "build") {
|
|
339
372
|
output(stdout, await buildProjectDirectory(root, {
|
|
340
373
|
output: parsed.flags.output ? resolve(cwd, parsed.flags.output) : undefined,
|
package/src/preview.js
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
const HEX = /^#(?:[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$/;
|
|
2
|
+
|
|
3
|
+
const COLORS = {
|
|
4
|
+
light: {
|
|
5
|
+
primary: "#006B58", on_primary: "#FFFFFF", primary_container: "#7CF8D6",
|
|
6
|
+
on_primary_container: "#002019", secondary_container: "#CDE9DE",
|
|
7
|
+
on_secondary_container: "#072019", tertiary_container: "#C2E8FC",
|
|
8
|
+
on_tertiary_container: "#001F29", error_container: "#FFDAD6",
|
|
9
|
+
on_error_container: "#410002", surface: "#F5FBF8", on_surface: "#171D1B",
|
|
10
|
+
surface_variant: "#DBE5E0", on_surface_variant: "#3F4945",
|
|
11
|
+
outline: "#6F7975", outline_variant: "#BEC9C4",
|
|
12
|
+
},
|
|
13
|
+
dark: {
|
|
14
|
+
primary: "#5CDBBA", on_primary: "#00382D", primary_container: "#005142",
|
|
15
|
+
on_primary_container: "#7CF8D6", secondary_container: "#344C45",
|
|
16
|
+
on_secondary_container: "#CDE9DE", tertiary_container: "#264B5B",
|
|
17
|
+
on_tertiary_container: "#C2E8FC", error_container: "#93000A",
|
|
18
|
+
on_error_container: "#FFDAD6", surface: "#0F1513", on_surface: "#DFE4E1",
|
|
19
|
+
surface_variant: "#3F4945", on_surface_variant: "#BEC9C4",
|
|
20
|
+
outline: "#89938F", outline_variant: "#3F4945",
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
function escape(value) {
|
|
25
|
+
return String(value ?? "")
|
|
26
|
+
.replaceAll("&", "&")
|
|
27
|
+
.replaceAll("<", "<")
|
|
28
|
+
.replaceAll(">", ">")
|
|
29
|
+
.replaceAll('"', """)
|
|
30
|
+
.replaceAll("'", "'");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function cssColor(value) {
|
|
34
|
+
if (typeof value !== "string" || !HEX.test(value)) return null;
|
|
35
|
+
return value.length === 9 ? "#" + value.slice(3) + value.slice(1, 3) : value;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function themeVariables(config) {
|
|
39
|
+
const theme = config?.theme || {};
|
|
40
|
+
const values = [];
|
|
41
|
+
for (const mode of ["light", "dark"]) {
|
|
42
|
+
const variant = { ...theme, ...(theme[mode] || {}) };
|
|
43
|
+
const colors = { ...COLORS[mode], ...(theme.color_scheme || {}), ...(variant.color_scheme || {}) };
|
|
44
|
+
for (const [role, fallback] of Object.entries(COLORS[mode])) {
|
|
45
|
+
values.push("--" + mode + "-" + role.replaceAll("_", "-") + ":" + (cssColor(colors[role]) || fallback));
|
|
46
|
+
}
|
|
47
|
+
values.push("--" + mode + "-radius:" + Number(variant.border?.radius ?? variant.radius ?? 18) + "px");
|
|
48
|
+
values.push("--" + mode + "-space:" + Number(variant.spacing ?? 12) + "px");
|
|
49
|
+
values.push("--" + mode + "-scale:" + Number(variant.typography?.scale ?? 1));
|
|
50
|
+
}
|
|
51
|
+
return values.join(";");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function nodeHtml(node) {
|
|
55
|
+
if (!node || typeof node !== "object") return "";
|
|
56
|
+
const type = String(node.type || "surface");
|
|
57
|
+
const props = node.props && typeof node.props === "object" ? node.props : {};
|
|
58
|
+
const children = Array.isArray(node.children) ? node.children.map(nodeHtml).join("") : "";
|
|
59
|
+
const label = escape(props.label || props.title || type);
|
|
60
|
+
const action = node.action ? ' data-action="' + escape(node.action.type) + '"' : "";
|
|
61
|
+
if (type === "text") return '<p class="ui-text ' + escape(props.style) + '">' + escape(props.text) + "</p>";
|
|
62
|
+
if (type === "icon") return '<span class="ui-icon" role="img" aria-label="' + escape(props.semantic_label || props.icon) + '">' + escape(props.icon) + "</span>";
|
|
63
|
+
if (type === "image") return '<div class="ui-image" role="img" aria-label="' + escape(props.semantic_label || "image") + '">🖼 ' + escape(props.asset || props.url || "image") + "</div>";
|
|
64
|
+
if (type === "avatar") return '<span class="ui-avatar">' + escape(props.label || "M").slice(0, 1) + "</span>";
|
|
65
|
+
if (type === "badge") return '<span class="ui-badge">' + label + children + "</span>";
|
|
66
|
+
if (type === "divider") return "<hr>";
|
|
67
|
+
if (type === "spacer") return '<span class="ui-spacer" aria-hidden="true"></span>';
|
|
68
|
+
if (type === "button") return "<button" + action + ">" + (props.icon ? escape(props.icon) + " " : "") + label + "</button>";
|
|
69
|
+
if (type === "chip") return '<span class="ui-chip"' + action + ">" + label + "</span>";
|
|
70
|
+
if (type === "metric") return '<div class="ui-metric"><strong>' + escape(props.value) + "</strong><span>" + label + "</span></div>";
|
|
71
|
+
if (type === "progress") return '<div class="ui-progress"><span>' + label + '</span><progress value="' + Number(props.value ?? .5) + '" max="1"></progress></div>';
|
|
72
|
+
if (type === "field") return '<label class="ui-field">' + label + '<input disabled placeholder="' + escape(props.hint || props.placeholder) + '"></label>';
|
|
73
|
+
if (type === "select") return '<label class="ui-field">' + label + '<select disabled><option>' + escape(props.placeholder || "선택") + "</option></select></label>";
|
|
74
|
+
if (type === "switch") return '<label class="ui-switch"><input type="checkbox" disabled> ' + label + "</label>";
|
|
75
|
+
if (type === "empty") return '<div class="ui-empty"><strong>' + escape(props.title || "표시할 내용이 없어요.") + "</strong><p>" + escape(props.supporting) + "</p></div>";
|
|
76
|
+
if (["list", "timeline", "calendar", "chart", "table"].includes(type)) {
|
|
77
|
+
return '<section class="ui-collection"><small>' + escape(type + (props.source ? " · " + props.source : "")) + "</small>" + (children || '<p>' + escape(props.empty_text || "데이터가 연결되면 표시됩니다.") + "</p>") + "</section>";
|
|
78
|
+
}
|
|
79
|
+
if (["dialog", "sheet"].includes(type)) return '<details class="ui-surface"><summary>' + label + "</summary>" + children + "</details>";
|
|
80
|
+
const layout = ["row", "wrap", "grid", "stack", "column"].includes(type);
|
|
81
|
+
const surface = ["card", "surface", "section", "form"].includes(type);
|
|
82
|
+
const className = layout ? "ui-layout " + type : surface ? "ui-surface " + type : "ui-layout " + type;
|
|
83
|
+
const heading = surface && (props.title || props.label)
|
|
84
|
+
? "<h3>" + label + "</h3>" + (props.subtitle ? "<p>" + escape(props.subtitle) + "</p>" : "")
|
|
85
|
+
: "";
|
|
86
|
+
return '<div class="' + className + '"' + action + ">" + heading + children + "</div>";
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function chromeHtml(config) {
|
|
90
|
+
const appBar = config.app_bar;
|
|
91
|
+
const navigation = config.navigation;
|
|
92
|
+
const bar = appBar
|
|
93
|
+
? '<header class="app-bar"><div><strong>' + escape(appBar.title) + "</strong>" +
|
|
94
|
+
(appBar.subtitle ? "<small>" + escape(appBar.subtitle) + "</small>" : "") +
|
|
95
|
+
'</div><div class="app-actions">' + (appBar.actions || []).map((item) => "<button>" + escape(item.icon || item.label) + "</button>").join("") + "</div></header>"
|
|
96
|
+
: "";
|
|
97
|
+
const nav = navigation
|
|
98
|
+
? '<nav class="ui-nav" aria-label="Plugin navigation">' + navigation.items.map((item) => '<span class="ui-chip">' + escape(item.icon ? item.icon + " " + item.label : item.label) + "</span>").join("") + "</nav>"
|
|
99
|
+
: "";
|
|
100
|
+
return bar + nav;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const CSS = [
|
|
104
|
+
"*{box-sizing:border-box}body{margin:0;font:15px/1.5 system-ui,sans-serif;background:#0d1211}",
|
|
105
|
+
".theme-toggle{position:fixed;right:18px;top:16px;z-index:3;padding:9px 13px;border-radius:999px;background:#fff;color:#17211f;box-shadow:0 4px 18px #0003;cursor:pointer}",
|
|
106
|
+
"#preview-dark{position:fixed;opacity:0}.preview{min-height:100vh;padding:72px 20px 56px;background:#eef3f1}",
|
|
107
|
+
".preview-head{max-width:980px;margin:0 auto 22px}.preview-head h1{margin:0}.screens{display:grid;gap:24px;max-width:980px;margin:auto}",
|
|
108
|
+
".screen{--primary:var(--light-primary);--on-primary:var(--light-on-primary);--primary-container:var(--light-primary-container);--on-primary-container:var(--light-on-primary-container);--secondary-container:var(--light-secondary-container);--on-secondary-container:var(--light-on-secondary-container);--tertiary-container:var(--light-tertiary-container);--on-tertiary-container:var(--light-on-tertiary-container);--error-container:var(--light-error-container);--on-error-container:var(--light-on-error-container);--surface:var(--light-surface);--on-surface:var(--light-on-surface);--surface-variant:var(--light-surface-variant);--on-surface-variant:var(--light-on-surface-variant);--outline:var(--light-outline);--outline-variant:var(--light-outline-variant);--radius:var(--light-radius);--space:var(--light-space);--scale:var(--light-scale);overflow:hidden;border:1px solid var(--outline-variant);border-radius:calc(var(--radius) + 6px);background:var(--surface);color:var(--on-surface);font-size:calc(1rem * var(--scale));box-shadow:0 12px 34px #0002}",
|
|
109
|
+
"#preview-dark:checked~.preview{background:#090d0c;color:#e1e5e2}#preview-dark:checked~.preview .screen{--primary:var(--dark-primary);--on-primary:var(--dark-on-primary);--primary-container:var(--dark-primary-container);--on-primary-container:var(--dark-on-primary-container);--secondary-container:var(--dark-secondary-container);--on-secondary-container:var(--dark-on-secondary-container);--tertiary-container:var(--dark-tertiary-container);--on-tertiary-container:var(--dark-on-tertiary-container);--error-container:var(--dark-error-container);--on-error-container:var(--dark-on-error-container);--surface:var(--dark-surface);--on-surface:var(--dark-on-surface);--surface-variant:var(--dark-surface-variant);--on-surface-variant:var(--dark-on-surface-variant);--outline:var(--dark-outline);--outline-variant:var(--dark-outline-variant);--radius:var(--dark-radius);--space:var(--dark-space);--scale:var(--dark-scale)}",
|
|
110
|
+
".screen-meta,.app-bar,.screen-body,.ui-nav{padding:var(--space)}.screen-meta{display:flex;gap:8px;background:var(--surface-variant);color:var(--on-surface-variant)}.screen-meta span{margin-right:auto}.app-bar{display:flex;align-items:center;border-bottom:1px solid var(--outline-variant)}.app-bar div:first-child{display:grid}.app-bar small{color:var(--on-surface-variant)}.app-actions{margin-left:auto;display:flex;gap:6px}",
|
|
111
|
+
".screen-body,.ui-layout,.ui-surface,.ui-collection{display:flex;flex-direction:column;gap:var(--space)}.ui-layout.row{flex-direction:row;align-items:center}.ui-layout.wrap,.ui-nav{display:flex;flex-flow:row wrap;gap:8px}.ui-layout.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr))}.ui-surface,.ui-collection,.ui-empty{padding:var(--space);border:1px solid var(--outline-variant);border-radius:var(--radius);background:color-mix(in srgb,var(--surface),var(--primary-container) 9%)}",
|
|
112
|
+
"h3,p{margin:0}.ui-text.heading{font-size:1.25em;font-weight:750}.ui-text.caption,small{color:var(--on-surface-variant)}button,.ui-chip{border:0;border-radius:999px;padding:8px 13px;background:var(--primary-container);color:var(--on-primary-container);font:inherit}.ui-icon{font:600 .75em monospace;color:var(--primary)}.ui-image{min-height:90px;display:grid;place-items:center;border-radius:var(--radius);background:var(--surface-variant);color:var(--on-surface-variant)}.ui-avatar{display:grid;place-items:center;width:42px;height:42px;border-radius:50%;background:var(--primary);color:var(--on-primary)}.ui-badge{padding:3px 7px;border-radius:99px;background:var(--error-container);color:var(--on-error-container)}.ui-metric{display:grid;padding:12px;border-radius:var(--radius);background:var(--secondary-container);color:var(--on-secondary-container)}.ui-metric strong{font-size:1.35em}.ui-field{display:grid;gap:5px}input,select{width:100%;padding:11px;border:1px solid var(--outline);border-radius:var(--radius);background:var(--surface);color:var(--on-surface)}progress{accent-color:var(--primary);width:100%}hr{width:100%;border:0;border-top:1px solid var(--outline-variant)}.ui-spacer{min-height:12px}.ui-nav{border-top:1px solid var(--outline-variant)}",
|
|
113
|
+
].join("");
|
|
114
|
+
|
|
115
|
+
export function renderPreviewHtml(manifest) {
|
|
116
|
+
const extensions = manifest.ui_extensions.filter((item) => item.config?.ui_schema === 2);
|
|
117
|
+
const screens = extensions.length
|
|
118
|
+
? extensions.map((extension) => {
|
|
119
|
+
const config = extension.config;
|
|
120
|
+
return '<article class="screen" style="' + themeVariables(config) + '">' +
|
|
121
|
+
'<div class="screen-meta"><span>' + escape(extension.point) + "</span><strong>" + escape(extension.title) + "</strong></div>" +
|
|
122
|
+
chromeHtml(config) + '<main class="screen-body">' + nodeHtml(config.view) + "</main></article>";
|
|
123
|
+
}).join("")
|
|
124
|
+
: '<article class="screen"><div class="ui-empty"><strong>UI extension 없음</strong><p>UI Runtime v2 화면을 추가하면 여기에 표시됩니다.</p></div></article>';
|
|
125
|
+
const title = escape(manifest.name);
|
|
126
|
+
const content = '<!doctype html><html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>' +
|
|
127
|
+
title + " preview</title><style>" + CSS +
|
|
128
|
+
'</style></head><body><input id="preview-dark" type="checkbox"><label class="theme-toggle" for="preview-dark">☀︎ / ☾</label><div class="preview"><header class="preview-head"><small>Morit UI Runtime v2 · 안전한 계약 Preview</small><h1>' +
|
|
129
|
+
title + "</h1><p>" + escape(manifest.description || "") +
|
|
130
|
+
'</p></header><section class="screens">' + screens + "</section></div></body></html>";
|
|
131
|
+
return Buffer.from(content, "utf8");
|
|
132
|
+
}
|
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
|
}
|