diff --git a/dist/index.cjs b/dist/index.cjs index e443ce7..ed8f3b2 100755 --- a/dist/index.cjs +++ b/dist/index.cjs @@ -2239,13 +2239,13 @@ function guessArchiveType(filename) { return "tar.gz"; } async function verifyHash(filePath, expectedHash) { - return new Promise((resolve3, reject) => { + return new Promise((resolve4, reject) => { const hash = import_crypto.default.createHash("sha256"); const stream = import_fs2.default.createReadStream(filePath); stream.on("data", (data) => hash.update(data)); stream.on("end", () => { const actualHash = hash.digest("hex"); - resolve3(actualHash === expectedHash); + resolve4(actualHash === expectedHash); }); stream.on("error", reject); }); @@ -2348,17 +2348,17 @@ var require_visit = __commonJS({ visit.BREAK = BREAK; visit.SKIP = SKIP; visit.REMOVE = REMOVE; - function visit_(key, node, visitor, path53) { - const ctrl = callVisitor(key, node, visitor, path53); + function visit_(key, node, visitor, path55) { + const ctrl = callVisitor(key, node, visitor, path55); if (identity.isNode(ctrl) || identity.isPair(ctrl)) { - replaceNode(key, path53, ctrl); - return visit_(key, ctrl, visitor, path53); + replaceNode(key, path55, ctrl); + return visit_(key, ctrl, visitor, path55); } if (typeof ctrl !== "symbol") { if (identity.isCollection(node)) { - path53 = Object.freeze(path53.concat(node)); + path55 = Object.freeze(path55.concat(node)); for (let i = 0; i < node.items.length; ++i) { - const ci = visit_(i, node.items[i], visitor, path53); + const ci = visit_(i, node.items[i], visitor, path55); if (typeof ci === "number") i = ci - 1; else if (ci === BREAK) @@ -2369,13 +2369,13 @@ var require_visit = __commonJS({ } } } else if (identity.isPair(node)) { - path53 = Object.freeze(path53.concat(node)); - const ck = visit_("key", node.key, visitor, path53); + path55 = Object.freeze(path55.concat(node)); + const ck = visit_("key", node.key, visitor, path55); if (ck === BREAK) return BREAK; else if (ck === REMOVE) node.key = null; - const cv = visit_("value", node.value, visitor, path53); + const cv = visit_("value", node.value, visitor, path55); if (cv === BREAK) return BREAK; else if (cv === REMOVE) @@ -2396,17 +2396,17 @@ var require_visit = __commonJS({ visitAsync.BREAK = BREAK; visitAsync.SKIP = SKIP; visitAsync.REMOVE = REMOVE; - async function visitAsync_(key, node, visitor, path53) { - const ctrl = await callVisitor(key, node, visitor, path53); + async function visitAsync_(key, node, visitor, path55) { + const ctrl = await callVisitor(key, node, visitor, path55); if (identity.isNode(ctrl) || identity.isPair(ctrl)) { - replaceNode(key, path53, ctrl); - return visitAsync_(key, ctrl, visitor, path53); + replaceNode(key, path55, ctrl); + return visitAsync_(key, ctrl, visitor, path55); } if (typeof ctrl !== "symbol") { if (identity.isCollection(node)) { - path53 = Object.freeze(path53.concat(node)); + path55 = Object.freeze(path55.concat(node)); for (let i = 0; i < node.items.length; ++i) { - const ci = await visitAsync_(i, node.items[i], visitor, path53); + const ci = await visitAsync_(i, node.items[i], visitor, path55); if (typeof ci === "number") i = ci - 1; else if (ci === BREAK) @@ -2417,13 +2417,13 @@ var require_visit = __commonJS({ } } } else if (identity.isPair(node)) { - path53 = Object.freeze(path53.concat(node)); - const ck = await visitAsync_("key", node.key, visitor, path53); + path55 = Object.freeze(path55.concat(node)); + const ck = await visitAsync_("key", node.key, visitor, path55); if (ck === BREAK) return BREAK; else if (ck === REMOVE) node.key = null; - const cv = await visitAsync_("value", node.value, visitor, path53); + const cv = await visitAsync_("value", node.value, visitor, path55); if (cv === BREAK) return BREAK; else if (cv === REMOVE) @@ -2450,23 +2450,23 @@ var require_visit = __commonJS({ } return visitor; } - function callVisitor(key, node, visitor, path53) { + function callVisitor(key, node, visitor, path55) { if (typeof visitor === "function") - return visitor(key, node, path53); + return visitor(key, node, path55); if (identity.isMap(node)) - return visitor.Map?.(key, node, path53); + return visitor.Map?.(key, node, path55); if (identity.isSeq(node)) - return visitor.Seq?.(key, node, path53); + return visitor.Seq?.(key, node, path55); if (identity.isPair(node)) - return visitor.Pair?.(key, node, path53); + return visitor.Pair?.(key, node, path55); if (identity.isScalar(node)) - return visitor.Scalar?.(key, node, path53); + return visitor.Scalar?.(key, node, path55); if (identity.isAlias(node)) - return visitor.Alias?.(key, node, path53); + return visitor.Alias?.(key, node, path55); return void 0; } - function replaceNode(key, path53, node) { - const parent = path53[path53.length - 1]; + function replaceNode(key, path55, node) { + const parent = path55[path55.length - 1]; if (identity.isCollection(parent)) { parent.items[key] = node; } else if (identity.isPair(parent)) { @@ -3074,10 +3074,10 @@ var require_Collection = __commonJS({ var createNode = require_createNode(); var identity = require_identity(); var Node = require_Node(); - function collectionFromPath(schema, path53, value) { + function collectionFromPath(schema, path55, value) { let v = value; - for (let i = path53.length - 1; i >= 0; --i) { - const k = path53[i]; + for (let i = path55.length - 1; i >= 0; --i) { + const k = path55[i]; if (typeof k === "number" && Number.isInteger(k) && k >= 0) { const a = []; a[k] = v; @@ -3096,7 +3096,7 @@ var require_Collection = __commonJS({ sourceObjects: /* @__PURE__ */ new Map() }); } - var isEmptyPath = (path53) => path53 == null || typeof path53 === "object" && !!path53[Symbol.iterator]().next().done; + var isEmptyPath = (path55) => path55 == null || typeof path55 === "object" && !!path55[Symbol.iterator]().next().done; var Collection = class extends Node.NodeBase { constructor(type, schema) { super(type); @@ -3126,11 +3126,11 @@ var require_Collection = __commonJS({ * be a Pair instance or a `{ key, value }` object, which may not have a key * that already exists in the map. */ - addIn(path53, value) { - if (isEmptyPath(path53)) + addIn(path55, value) { + if (isEmptyPath(path55)) this.add(value); else { - const [key, ...rest] = path53; + const [key, ...rest] = path55; const node = this.get(key, true); if (identity.isCollection(node)) node.addIn(rest, value); @@ -3144,8 +3144,8 @@ var require_Collection = __commonJS({ * Removes a value from the collection. * @returns `true` if the item was found and removed. */ - deleteIn(path53) { - const [key, ...rest] = path53; + deleteIn(path55) { + const [key, ...rest] = path55; if (rest.length === 0) return this.delete(key); const node = this.get(key, true); @@ -3159,8 +3159,8 @@ var require_Collection = __commonJS({ * scalar values from their surrounding node; to disable set `keepScalar` to * `true` (collections are always returned intact). */ - getIn(path53, keepScalar) { - const [key, ...rest] = path53; + getIn(path55, keepScalar) { + const [key, ...rest] = path55; const node = this.get(key, true); if (rest.length === 0) return !keepScalar && identity.isScalar(node) ? node.value : node; @@ -3178,8 +3178,8 @@ var require_Collection = __commonJS({ /** * Checks if the collection includes a value with the key `key`. */ - hasIn(path53) { - const [key, ...rest] = path53; + hasIn(path55) { + const [key, ...rest] = path55; if (rest.length === 0) return this.has(key); const node = this.get(key, true); @@ -3189,8 +3189,8 @@ var require_Collection = __commonJS({ * Sets a value in this collection. For `!!set`, `value` needs to be a * boolean to add/remove the item from the set. */ - setIn(path53, value) { - const [key, ...rest] = path53; + setIn(path55, value) { + const [key, ...rest] = path55; if (rest.length === 0) { this.set(key, value); } else { @@ -5694,9 +5694,9 @@ var require_Document = __commonJS({ this.contents.add(value); } /** Adds a value to the document. */ - addIn(path53, value) { + addIn(path55, value) { if (assertCollection(this.contents)) - this.contents.addIn(path53, value); + this.contents.addIn(path55, value); } /** * Create a new `Alias` node, ensuring that the target `node` has the required anchor. @@ -5771,14 +5771,14 @@ var require_Document = __commonJS({ * Removes a value from the document. * @returns `true` if the item was found and removed. */ - deleteIn(path53) { - if (Collection.isEmptyPath(path53)) { + deleteIn(path55) { + if (Collection.isEmptyPath(path55)) { if (this.contents == null) return false; this.contents = null; return true; } - return assertCollection(this.contents) ? this.contents.deleteIn(path53) : false; + return assertCollection(this.contents) ? this.contents.deleteIn(path55) : false; } /** * Returns item at `key`, or `undefined` if not found. By default unwraps @@ -5793,10 +5793,10 @@ var require_Document = __commonJS({ * scalar values from their surrounding node; to disable set `keepScalar` to * `true` (collections are always returned intact). */ - getIn(path53, keepScalar) { - if (Collection.isEmptyPath(path53)) + getIn(path55, keepScalar) { + if (Collection.isEmptyPath(path55)) return !keepScalar && identity.isScalar(this.contents) ? this.contents.value : this.contents; - return identity.isCollection(this.contents) ? this.contents.getIn(path53, keepScalar) : void 0; + return identity.isCollection(this.contents) ? this.contents.getIn(path55, keepScalar) : void 0; } /** * Checks if the document includes a value with the key `key`. @@ -5807,10 +5807,10 @@ var require_Document = __commonJS({ /** * Checks if the document includes a value at `path`. */ - hasIn(path53) { - if (Collection.isEmptyPath(path53)) + hasIn(path55) { + if (Collection.isEmptyPath(path55)) return this.contents !== void 0; - return identity.isCollection(this.contents) ? this.contents.hasIn(path53) : false; + return identity.isCollection(this.contents) ? this.contents.hasIn(path55) : false; } /** * Sets a value in this document. For `!!set`, `value` needs to be a @@ -5827,13 +5827,13 @@ var require_Document = __commonJS({ * Sets a value in this document. For `!!set`, `value` needs to be a * boolean to add/remove the item from the set. */ - setIn(path53, value) { - if (Collection.isEmptyPath(path53)) { + setIn(path55, value) { + if (Collection.isEmptyPath(path55)) { this.contents = value; } else if (this.contents == null) { - this.contents = Collection.collectionFromPath(this.schema, Array.from(path53), value); + this.contents = Collection.collectionFromPath(this.schema, Array.from(path55), value); } else if (assertCollection(this.contents)) { - this.contents.setIn(path53, value); + this.contents.setIn(path55, value); } } /** @@ -6219,10 +6219,10 @@ var require_resolve_block_map = __commonJS({ let offset = bm.offset; let commentEnd = null; for (const collItem of bm.items) { - const { start, key, sep: sep3, value } = collItem; + const { start, key, sep: sep4, value } = collItem; const keyProps = resolveProps.resolveProps(start, { indicator: "explicit-key-ind", - next: key ?? sep3?.[0], + next: key ?? sep4?.[0], offset, onError, parentIndent: bm.indent, @@ -6236,7 +6236,7 @@ var require_resolve_block_map = __commonJS({ else if ("indent" in key && key.indent !== bm.indent) onError(offset, "BAD_INDENT", startColMsg); } - if (!keyProps.anchor && !keyProps.tag && !sep3) { + if (!keyProps.anchor && !keyProps.tag && !sep4) { commentEnd = keyProps.end; if (keyProps.comment) { if (map.comment) @@ -6260,7 +6260,7 @@ var require_resolve_block_map = __commonJS({ ctx.atKey = false; if (utilMapIncludes.mapIncludes(ctx, map.items, keyNode)) onError(keyStart, "DUPLICATE_KEY", "Map keys must be unique"); - const valueProps = resolveProps.resolveProps(sep3 ?? [], { + const valueProps = resolveProps.resolveProps(sep4 ?? [], { indicator: "map-value-ind", next: value, offset: keyNode.range[2], @@ -6276,7 +6276,7 @@ var require_resolve_block_map = __commonJS({ if (ctx.options.strict && keyProps.start < valueProps.found.offset - 1024) onError(keyNode.range, "KEY_OVER_1024_CHARS", "The : indicator must be at most 1024 chars after the start of an implicit block mapping key"); } - const valueNode = value ? composeNode(ctx, value, valueProps, onError) : composeEmptyNode(ctx, offset, sep3, null, valueProps, onError); + const valueNode = value ? composeNode(ctx, value, valueProps, onError) : composeEmptyNode(ctx, offset, sep4, null, valueProps, onError); if (ctx.schema.compat) utilFlowIndentCheck.flowIndentCheck(bm.indent, value, onError); offset = valueNode.range[2]; @@ -6367,7 +6367,7 @@ var require_resolve_end = __commonJS({ let comment = ""; if (end) { let hasSpace = false; - let sep3 = ""; + let sep4 = ""; for (const token of end) { const { source, type } = token; switch (type) { @@ -6381,13 +6381,13 @@ var require_resolve_end = __commonJS({ if (!comment) comment = cb; else - comment += sep3 + cb; - sep3 = ""; + comment += sep4 + cb; + sep4 = ""; break; } case "newline": if (comment) - sep3 += source; + sep4 += source; hasSpace = true; break; default: @@ -6430,18 +6430,18 @@ var require_resolve_flow_collection = __commonJS({ let offset = fc.offset + fc.start.source.length; for (let i = 0; i < fc.items.length; ++i) { const collItem = fc.items[i]; - const { start, key, sep: sep3, value } = collItem; + const { start, key, sep: sep4, value } = collItem; const props = resolveProps.resolveProps(start, { flow: fcName, indicator: "explicit-key-ind", - next: key ?? sep3?.[0], + next: key ?? sep4?.[0], offset, onError, parentIndent: fc.indent, startOnNewline: false }); if (!props.found) { - if (!props.anchor && !props.tag && !sep3 && !value) { + if (!props.anchor && !props.tag && !sep4 && !value) { if (i === 0 && props.comma) onError(props.comma, "UNEXPECTED_TOKEN", `Unexpected , in ${fcName}`); else if (i < fc.items.length - 1) @@ -6495,8 +6495,8 @@ var require_resolve_flow_collection = __commonJS({ } } } - if (!isMap && !sep3 && !props.found) { - const valueNode = value ? composeNode(ctx, value, props, onError) : composeEmptyNode(ctx, props.end, sep3, null, props, onError); + if (!isMap && !sep4 && !props.found) { + const valueNode = value ? composeNode(ctx, value, props, onError) : composeEmptyNode(ctx, props.end, sep4, null, props, onError); coll.items.push(valueNode); offset = valueNode.range[2]; if (isBlock(value)) @@ -6508,7 +6508,7 @@ var require_resolve_flow_collection = __commonJS({ if (isBlock(key)) onError(keyNode.range, "BLOCK_IN_FLOW", blockMsg); ctx.atKey = false; - const valueProps = resolveProps.resolveProps(sep3 ?? [], { + const valueProps = resolveProps.resolveProps(sep4 ?? [], { flow: fcName, indicator: "map-value-ind", next: value, @@ -6519,8 +6519,8 @@ var require_resolve_flow_collection = __commonJS({ }); if (valueProps.found) { if (!isMap && !props.found && ctx.options.strict) { - if (sep3) - for (const st of sep3) { + if (sep4) + for (const st of sep4) { if (st === valueProps.found) break; if (st.type === "newline") { @@ -6537,7 +6537,7 @@ var require_resolve_flow_collection = __commonJS({ else onError(valueProps.start, "MISSING_CHAR", `Missing , or : between ${fcName} items`); } - const valueNode = value ? composeNode(ctx, value, valueProps, onError) : valueProps.found ? composeEmptyNode(ctx, valueProps.end, sep3, null, valueProps, onError) : null; + const valueNode = value ? composeNode(ctx, value, valueProps, onError) : valueProps.found ? composeEmptyNode(ctx, valueProps.end, sep4, null, valueProps, onError) : null; if (valueNode) { if (isBlock(value)) onError(valueNode.range, "BLOCK_IN_FLOW", blockMsg); @@ -6717,7 +6717,7 @@ var require_resolve_block_scalar = __commonJS({ chompStart = i + 1; } let value = ""; - let sep3 = ""; + let sep4 = ""; let prevMoreIndented = false; for (let i = 0; i < contentStart; ++i) value += lines[i][0].slice(trimIndent) + "\n"; @@ -6734,24 +6734,24 @@ var require_resolve_block_scalar = __commonJS({ indent = ""; } if (type === Scalar.Scalar.BLOCK_LITERAL) { - value += sep3 + indent.slice(trimIndent) + content; - sep3 = "\n"; + value += sep4 + indent.slice(trimIndent) + content; + sep4 = "\n"; } else if (indent.length > trimIndent || content[0] === " ") { - if (sep3 === " ") - sep3 = "\n"; - else if (!prevMoreIndented && sep3 === "\n") - sep3 = "\n\n"; - value += sep3 + indent.slice(trimIndent) + content; - sep3 = "\n"; + if (sep4 === " ") + sep4 = "\n"; + else if (!prevMoreIndented && sep4 === "\n") + sep4 = "\n\n"; + value += sep4 + indent.slice(trimIndent) + content; + sep4 = "\n"; prevMoreIndented = true; } else if (content === "") { - if (sep3 === "\n") + if (sep4 === "\n") value += "\n"; else - sep3 = "\n"; + sep4 = "\n"; } else { - value += sep3 + content; - sep3 = " "; + value += sep4 + content; + sep4 = " "; prevMoreIndented = false; } } @@ -6933,25 +6933,25 @@ var require_resolve_flow_scalar = __commonJS({ if (!match) return source; let res = match[1]; - let sep3 = " "; + let sep4 = " "; let pos = first.lastIndex; line.lastIndex = pos; while (match = line.exec(source)) { if (match[1] === "") { - if (sep3 === "\n") - res += sep3; + if (sep4 === "\n") + res += sep4; else - sep3 = "\n"; + sep4 = "\n"; } else { - res += sep3 + match[1]; - sep3 = " "; + res += sep4 + match[1]; + sep4 = " "; } pos = line.lastIndex; } const last = /[ \t]*(.*)/sy; last.lastIndex = pos; match = last.exec(source); - return res + sep3 + (match?.[1] ?? ""); + return res + sep4 + (match?.[1] ?? ""); } function doubleQuotedValue(source, onError) { let res = ""; @@ -7753,14 +7753,14 @@ var require_cst_stringify = __commonJS({ } } } - function stringifyItem({ start, key, sep: sep3, value }) { + function stringifyItem({ start, key, sep: sep4, value }) { let res = ""; for (const st of start) res += st.source; if (key) res += stringifyToken(key); - if (sep3) - for (const st of sep3) + if (sep4) + for (const st of sep4) res += st.source; if (value) res += stringifyToken(value); @@ -7785,9 +7785,9 @@ var require_cst_visit = __commonJS({ visit.BREAK = BREAK; visit.SKIP = SKIP; visit.REMOVE = REMOVE; - visit.itemAtPath = (cst, path53) => { + visit.itemAtPath = (cst, path55) => { let item = cst; - for (const [field, index] of path53) { + for (const [field, index] of path55) { const tok = item?.[field]; if (tok && "items" in tok) { item = tok.items[index]; @@ -7796,23 +7796,23 @@ var require_cst_visit = __commonJS({ } return item; }; - visit.parentCollection = (cst, path53) => { - const parent = visit.itemAtPath(cst, path53.slice(0, -1)); - const field = path53[path53.length - 1][0]; + visit.parentCollection = (cst, path55) => { + const parent = visit.itemAtPath(cst, path55.slice(0, -1)); + const field = path55[path55.length - 1][0]; const coll = parent?.[field]; if (coll && "items" in coll) return coll; throw new Error("Parent collection not found"); }; - function _visit(path53, item, visitor) { - let ctrl = visitor(item, path53); + function _visit(path55, item, visitor) { + let ctrl = visitor(item, path55); if (typeof ctrl === "symbol") return ctrl; for (const field of ["key", "value"]) { const token = item[field]; if (token && "items" in token) { for (let i = 0; i < token.items.length; ++i) { - const ci = _visit(Object.freeze(path53.concat([[field, i]])), token.items[i], visitor); + const ci = _visit(Object.freeze(path55.concat([[field, i]])), token.items[i], visitor); if (typeof ci === "number") i = ci - 1; else if (ci === BREAK) @@ -7823,10 +7823,10 @@ var require_cst_visit = __commonJS({ } } if (typeof ctrl === "function" && field === "key") - ctrl = ctrl(item, path53); + ctrl = ctrl(item, path55); } } - return typeof ctrl === "function" ? ctrl(item, path53) : ctrl; + return typeof ctrl === "function" ? ctrl(item, path55) : ctrl; } exports2.visit = visit; } @@ -8910,18 +8910,18 @@ var require_parser = __commonJS({ if (this.type === "map-value-ind") { const prev = getPrevProps(this.peek(2)); const start = getFirstKeyStartProps(prev); - let sep3; + let sep4; if (scalar.end) { - sep3 = scalar.end; - sep3.push(this.sourceToken); + sep4 = scalar.end; + sep4.push(this.sourceToken); delete scalar.end; } else - sep3 = [this.sourceToken]; + sep4 = [this.sourceToken]; const map = { type: "block-map", offset: scalar.offset, indent: scalar.indent, - items: [{ start, key: scalar, sep: sep3 }] + items: [{ start, key: scalar, sep: sep4 }] }; this.onKeyLine = true; this.stack[this.stack.length - 1] = map; @@ -9074,15 +9074,15 @@ var require_parser = __commonJS({ } else if (isFlowToken(it.key) && !includesToken(it.sep, "newline")) { const start2 = getFirstKeyStartProps(it.start); const key = it.key; - const sep3 = it.sep; - sep3.push(this.sourceToken); + const sep4 = it.sep; + sep4.push(this.sourceToken); delete it.key; delete it.sep; this.stack.push({ type: "block-map", offset: this.offset, indent: this.indent, - items: [{ start: start2, key, sep: sep3 }] + items: [{ start: start2, key, sep: sep4 }] }); } else if (start.length > 0) { it.sep = it.sep.concat(start, this.sourceToken); @@ -9111,14 +9111,14 @@ var require_parser = __commonJS({ case "scalar": case "single-quoted-scalar": case "double-quoted-scalar": { - const fs52 = this.flowScalar(this.type); + const fs54 = this.flowScalar(this.type); if (atNextItem || it.value) { - map.items.push({ start, key: fs52, sep: [] }); + map.items.push({ start, key: fs54, sep: [] }); this.onKeyLine = true; } else if (it.sep) { - this.stack.push(fs52); + this.stack.push(fs54); } else { - Object.assign(it, { key: fs52, sep: [] }); + Object.assign(it, { key: fs54, sep: [] }); this.onKeyLine = true; } return; @@ -9246,13 +9246,13 @@ var require_parser = __commonJS({ case "scalar": case "single-quoted-scalar": case "double-quoted-scalar": { - const fs52 = this.flowScalar(this.type); + const fs54 = this.flowScalar(this.type); if (!it || it.value) - fc.items.push({ start: [], key: fs52, sep: [] }); + fc.items.push({ start: [], key: fs54, sep: [] }); else if (it.sep) - this.stack.push(fs52); + this.stack.push(fs54); else - Object.assign(it, { key: fs52, sep: [] }); + Object.assign(it, { key: fs54, sep: [] }); return; } case "flow-map-end": @@ -9276,13 +9276,13 @@ var require_parser = __commonJS({ const prev = getPrevProps(parent); const start = getFirstKeyStartProps(prev); fixFlowSeqItems(fc); - const sep3 = fc.end.splice(1, fc.end.length); - sep3.push(this.sourceToken); + const sep4 = fc.end.splice(1, fc.end.length); + sep4.push(this.sourceToken); const map = { type: "block-map", offset: fc.offset, indent: fc.indent, - items: [{ start, key: fc, sep: sep3 }] + items: [{ start, key: fc, sep: sep4 }] }; this.onKeyLine = true; this.stack[this.stack.length - 1] = map; @@ -12486,6 +12486,8 @@ function addStack(stackId, stackInfo) { stack: stackId, required: secret.required }; + } else if (config.secrets[secret.name].stack === stackId) { + config.secrets[secret.name].required = secret.required; } } }); @@ -12642,7 +12644,7 @@ async function discoverStackTools(stackId, stackConfig, options = {}) { const env = { ...process.env, ...secrets }; prependRudiExecutionPath(env); log(` Spawning ${stackId}...`); - return new Promise((resolve3) => { + return new Promise((resolve4) => { let resolved = false; let childProcess; let childProcessStartedAtMs = null; @@ -12764,7 +12766,7 @@ async function discoverStackTools(stackId, stackConfig, options = {}) { if (!resolved) { resolved = true; cleanup(); - resolve3({ + resolve4({ tools: [], error: `Timeout after ${timeout}ms`, missingSecrets: [] @@ -12820,7 +12822,7 @@ async function discoverStackTools(stackId, stackConfig, options = {}) { resolved = true; clearTimeout(timeoutId); cleanup(); - resolve3({ + resolve4({ tools: [], error: `Spawn error: ${err.message}`, missingSecrets: [] @@ -12833,7 +12835,7 @@ async function discoverStackTools(stackId, stackConfig, options = {}) { resolved = true; clearTimeout(timeoutId); cleanup(); - resolve3({ + resolve4({ tools: [], error: `Process exited with code ${code}`, missingSecrets: [] @@ -12861,7 +12863,7 @@ async function discoverStackTools(stackId, stackConfig, options = {}) { resolved = true; clearTimeout(timeoutId); cleanup(); - resolve3({ + resolve4({ tools, error: null, missingSecrets: [] @@ -12872,7 +12874,7 @@ async function discoverStackTools(stackId, stackConfig, options = {}) { resolved = true; clearTimeout(timeoutId); cleanup(); - resolve3({ + resolve4({ tools: [], error: err.message, missingSecrets: [] @@ -12884,7 +12886,7 @@ async function discoverStackTools(stackId, stackConfig, options = {}) { if (!resolved) { resolved = true; clearTimeout(timeoutId); - resolve3({ + resolve4({ tools: [], error: `Failed to spawn: ${err.message}`, missingSecrets: [] @@ -13194,6 +13196,16 @@ function loadSecrets2() { return {}; } } +function loadSecretsWithoutMutation() { + try { + if (!fs11.existsSync(SECRETS_FILE)) return {}; + const content = fs11.readFileSync(SECRETS_FILE, "utf-8"); + const secrets = JSON.parse(content); + return isSecretsObject(secrets) ? secrets : {}; + } catch { + return {}; + } +} function saveSecrets(secrets) { ensureSecretsFile(); const normalized = isSecretsObject(secrets) ? secrets : {}; @@ -13235,19 +13247,20 @@ async function listSecrets() { return Object.keys(secrets).sort(); } async function hasSecret(name) { - const secrets = loadSecrets2(); - return secrets[name] !== void 0 && secrets[name] !== null && secrets[name] !== ""; + const secrets = loadSecretsWithoutMutation(); + const value = secrets[name]; + return typeof value === "string" && value.trim() !== ""; } async function getMaskedSecrets() { const secrets = loadSecrets2(); const masked = {}; for (const [name, value] of Object.entries(secrets)) { - if (value && typeof value === "string" && value.length > 8) { + if (typeof value !== "string" || value.trim() === "") { + masked[name] = "(pending)"; + } else if (value.length > 8) { masked[name] = value.slice(0, 4) + "..." + value.slice(-4); - } else if (value && typeof value === "string" && value.length > 0) { - masked[name] = "****"; } else { - masked[name] = "(pending)"; + masked[name] = "****"; } } return masked; @@ -16651,7 +16664,7 @@ var require_compile = __commonJS({ const schOrFunc = root.refs[ref]; if (schOrFunc) return schOrFunc; - let _sch = resolve3.call(this, root, ref); + let _sch = resolve4.call(this, root, ref); if (_sch === void 0) { const schema = (_a = root.localRefs) === null || _a === void 0 ? void 0 : _a[ref]; const { schemaId } = this.opts; @@ -16678,7 +16691,7 @@ var require_compile = __commonJS({ function sameSchemaEnv(s1, s2) { return s1.schema === s2.schema && s1.root === s2.root && s1.baseId === s2.baseId; } - function resolve3(root, ref) { + function resolve4(root, ref) { let sch; while (typeof (sch = this.refs[ref]) == "string") ref = sch; @@ -16893,8 +16906,8 @@ var require_utils = __commonJS({ } return ind; } - function removeDotSegments(path53) { - let input = path53; + function removeDotSegments(path55) { + let input = path55; const output = []; let nextSlash = -1; let len = 0; @@ -17093,8 +17106,8 @@ var require_schemes = __commonJS({ wsComponent.secure = void 0; } if (wsComponent.resourceName) { - const [path53, query] = wsComponent.resourceName.split("?"); - wsComponent.path = path53 && path53 !== "/" ? path53 : void 0; + const [path55, query] = wsComponent.resourceName.split("?"); + wsComponent.path = path55 && path55 !== "/" ? path55 : void 0; wsComponent.query = query; wsComponent.resourceName = void 0; } @@ -17253,55 +17266,55 @@ var require_fast_uri = __commonJS({ } return uri; } - function resolve3(baseURI, relativeURI, options) { + function resolve4(baseURI, relativeURI, options) { const schemelessOptions = options ? Object.assign({ scheme: "null" }, options) : { scheme: "null" }; const resolved = resolveComponent(parse(baseURI, schemelessOptions), parse(relativeURI, schemelessOptions), schemelessOptions, true); schemelessOptions.skipEscape = true; return serialize(resolved, schemelessOptions); } - function resolveComponent(base, relative3, options, skipNormalization) { + function resolveComponent(base, relative4, options, skipNormalization) { const target = {}; if (!skipNormalization) { base = parse(serialize(base, options), options); - relative3 = parse(serialize(relative3, options), options); + relative4 = parse(serialize(relative4, options), options); } options = options || {}; - if (!options.tolerant && relative3.scheme) { - target.scheme = relative3.scheme; - target.userinfo = relative3.userinfo; - target.host = relative3.host; - target.port = relative3.port; - target.path = removeDotSegments(relative3.path || ""); - target.query = relative3.query; + if (!options.tolerant && relative4.scheme) { + target.scheme = relative4.scheme; + target.userinfo = relative4.userinfo; + target.host = relative4.host; + target.port = relative4.port; + target.path = removeDotSegments(relative4.path || ""); + target.query = relative4.query; } else { - if (relative3.userinfo !== void 0 || relative3.host !== void 0 || relative3.port !== void 0) { - target.userinfo = relative3.userinfo; - target.host = relative3.host; - target.port = relative3.port; - target.path = removeDotSegments(relative3.path || ""); - target.query = relative3.query; + if (relative4.userinfo !== void 0 || relative4.host !== void 0 || relative4.port !== void 0) { + target.userinfo = relative4.userinfo; + target.host = relative4.host; + target.port = relative4.port; + target.path = removeDotSegments(relative4.path || ""); + target.query = relative4.query; } else { - if (!relative3.path) { + if (!relative4.path) { target.path = base.path; - if (relative3.query !== void 0) { - target.query = relative3.query; + if (relative4.query !== void 0) { + target.query = relative4.query; } else { target.query = base.query; } } else { - if (relative3.path[0] === "/") { - target.path = removeDotSegments(relative3.path); + if (relative4.path[0] === "/") { + target.path = removeDotSegments(relative4.path); } else { if ((base.userinfo !== void 0 || base.host !== void 0 || base.port !== void 0) && !base.path) { - target.path = "/" + relative3.path; + target.path = "/" + relative4.path; } else if (!base.path) { - target.path = relative3.path; + target.path = relative4.path; } else { - target.path = base.path.slice(0, base.path.lastIndexOf("/") + 1) + relative3.path; + target.path = base.path.slice(0, base.path.lastIndexOf("/") + 1) + relative4.path; } target.path = removeDotSegments(target.path); } - target.query = relative3.query; + target.query = relative4.query; } target.userinfo = base.userinfo; target.host = base.host; @@ -17309,7 +17322,7 @@ var require_fast_uri = __commonJS({ } target.scheme = base.scheme; } - target.fragment = relative3.fragment; + target.fragment = relative4.fragment; return target; } function equal(uriA, uriB, options) { @@ -17480,7 +17493,7 @@ var require_fast_uri = __commonJS({ var fastUri = { SCHEMES, normalize: normalize2, - resolve: resolve3, + resolve: resolve4, resolveComponent, equal, serialize, @@ -20447,12 +20460,12 @@ var require_dist2 = __commonJS({ throw new Error(`Unknown format "${name}"`); return f; }; - function addFormats2(ajv2, list, fs52, exportName) { + function addFormats2(ajv2, list, fs54, exportName) { var _a; var _b; (_a = (_b = ajv2.opts.code).formats) !== null && _a !== void 0 ? _a : _b.formats = (0, codegen_1._)`require("ajv-formats/dist/formats").${exportName}`; for (const f of list) - ajv2.addFormat(f, fs52[f]); + ajv2.addFormat(f, fs54[f]); } module2.exports = exports2 = formatsPlugin; Object.defineProperty(exports2, "__esModule", { value: true }); @@ -23108,7 +23121,7 @@ async function attachAgentLaunch(launchId, dependencies = {}) { if (buffered.trim()) renderLine(buffered); return launch; } - await new Promise((resolve3) => setTimeout(resolve3, pollIntervalMs)); + await new Promise((resolve4) => setTimeout(resolve4, pollIntervalMs)); } return store.get(launchId); } finally { @@ -25355,7 +25368,7 @@ function executeForegroundLaunch({ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 24 * 60 * 60 * 1e3) { throw new Error("timeoutMs must be an integer between 1 and 86400000"); } - return new Promise((resolve3, reject) => { + return new Promise((resolve4, reject) => { const privateAutomation = plan.privateAutomationProfile != null; const normalizer = createAgentEventNormalizer(plan.provider); let child; @@ -25588,7 +25601,7 @@ function executeForegroundLaunch({ if (jsonOutput) { if (!privateAutomation) writeLine2(stdout, JSON.stringify(terminalEvent)); } - resolve3(updated); + resolve4(updated); } const runtimeTimer = setTimeout(() => { timedOut = true; @@ -25796,14 +25809,14 @@ function createWorkspaceManifest(rootDirectory) { function visit(directory, prefix = "") { const children = import_node_fs11.default.readdirSync(directory, { withFileTypes: true }).sort((left, right) => left.name.localeCompare(right.name)); for (const child of children) { - const relative3 = prefix ? import_node_path11.default.join(prefix, child.name) : child.name; - if (shouldSkip(relative3)) continue; + const relative4 = prefix ? import_node_path11.default.join(prefix, child.name) : child.name; + if (shouldSkip(relative4)) continue; const absolute = import_node_path11.default.join(directory, child.name); const stat = import_node_fs11.default.lstatSync(absolute); - const key = portablePath(relative3); + const key = portablePath(relative4); if (stat.isDirectory()) { entries[key] = { mode: stat.mode & 511, type: "directory" }; - visit(absolute, relative3); + visit(absolute, relative4); } else if (stat.isFile()) { entries[key] = { hash: hashFile(absolute), @@ -25900,8 +25913,8 @@ function existingDirectory2(candidate, label) { return import_node_fs12.default.realpathSync(resolved); } function isInside2(candidate, parent) { - const relative3 = import_node_path12.default.relative(parent, candidate); - return relative3 === "" || !relative3.startsWith(`..${import_node_path12.default.sep}`) && relative3 !== ".." && !import_node_path12.default.isAbsolute(relative3); + const relative4 = import_node_path12.default.relative(parent, candidate); + return relative4 === "" || !relative4.startsWith(`..${import_node_path12.default.sep}`) && relative4 !== ".." && !import_node_path12.default.isAbsolute(relative4); } function findGitProjectRoot(workspace, execFileSyncImpl) { try { @@ -25978,8 +25991,8 @@ function copyIsolatedWorkspace({ destination, projectRoot }) { import_node_fs12.default.cpSync(projectRoot, destination, { errorOnExist: true, filter(candidate) { - const relative3 = import_node_path12.default.relative(projectRoot, candidate); - const firstPart = relative3.split(import_node_path12.default.sep)[0]; + const relative4 = import_node_path12.default.relative(projectRoot, candidate); + const firstPart = relative4.split(import_node_path12.default.sep)[0]; if (firstPart === ".git" || firstPart === ".rudi") return false; const stat = import_node_fs12.default.lstatSync(candidate); if (stat.isSymbolicLink()) { @@ -26383,7 +26396,7 @@ async function dispatchDetachedAgent({ launchId, operation, options }, dependenc if (Buffer.byteLength(request, "utf8") > MAX_WORKER_REQUEST_BYTES) { throw new Error(`Detached worker request exceeds ${MAX_WORKER_REQUEST_BYTES} bytes`); } - return await new Promise((resolve3, reject) => { + return await new Promise((resolve4, reject) => { let buffer = ""; let settled = false; const child = spawnImpl(nodePath, [entrypoint, "agent", "_worker", launchId], { @@ -26408,7 +26421,7 @@ async function dispatchDetachedAgent({ launchId, operation, options }, dependenc child.stdout?.destroy?.(); child.unref?.(); if (error) reject(error); - else resolve3(launch); + else resolve4(launch); } child.once("spawn", () => { child.stdin.end(`${request} @@ -26569,7 +26582,7 @@ async function stopAgentLaunch(launchId, dependencies = {}) { if (TERMINAL_STATUSES3.has(current2.status)) { return { alreadyTerminal: false, launch: current2 }; } - await new Promise((resolve3) => setTimeout(resolve3, pollIntervalMs)); + await new Promise((resolve4) => setTimeout(resolve4, pollIntervalMs)); } const current = store.get(launchId); if (current.ownerPid && verifyWorkerImpl(current, dependencies)) { @@ -26615,8 +26628,8 @@ function noIndexDiff(execFileSyncImpl, left, right) { } } function isInside3(candidate, parent) { - const relative3 = import_node_path14.default.relative(parent, candidate); - return relative3 === "" || !relative3.startsWith(`..${import_node_path14.default.sep}`) && relative3 !== ".." && !import_node_path14.default.isAbsolute(relative3); + const relative4 = import_node_path14.default.relative(parent, candidate); + return relative4 === "" || !relative4.startsWith(`..${import_node_path14.default.sep}`) && relative4 !== ".." && !import_node_path14.default.isAbsolute(relative4); } function safeRelative(root, relativePath) { if (typeof relativePath !== "string" || relativePath === "" || relativePath.includes("\0")) { @@ -27612,7 +27625,7 @@ var DEFAULT_START_TIMEOUT_MS2 = 45e3; var DEFAULT_STOP_TIMEOUT_MS = 1e4; var DEFAULT_POLL_INTERVAL_MS = 250; function sleep(ms) { - return new Promise((resolve3) => setTimeout(resolve3, ms)); + return new Promise((resolve4) => setTimeout(resolve4, ms)); } function isOfflineStatus(status) { return status?.reason === "not_running" || status?.reason === "unreachable" || status?.reason === "invalid_connection_files"; @@ -29521,7 +29534,14 @@ function validateStackEntryPoint(stackPath, manifest) { return { valid: true }; } async function buildStackIfNeeded(stackPath, manifest, options = {}) { - const { nodeProject, verbose = false, allowScripts = true } = options; + const { + allowScripts = true, + force = false, + nodeProject, + npmCommand, + runBuildCommand = runCommand, + verbose = false + } = options; const runtime = getStackRuntime(manifest); if (runtime !== "node") { return { built: false, reason: "Non-node runtime" }; @@ -29530,7 +29550,7 @@ async function buildStackIfNeeded(stackPath, manifest, options = {}) { if (entryPoint.error) { return { built: false, reason: entryPoint.error }; } - if (!entryPoint.entryPath || fsSync.existsSync(entryPoint.entryPath)) { + if (!entryPoint.entryPath || fsSync.existsSync(entryPoint.entryPath) && !force) { return { built: false, reason: "Entry point already present" }; } const project = nodeProject || getNodeProjectInfo(stackPath); @@ -29548,10 +29568,10 @@ async function buildStackIfNeeded(stackPath, manifest, options = {}) { "External stack build scripts are disabled by default; review the pinned source and rerun with --allow-scripts" ); } - const npmCmd = getBundledBinary("node", "npm"); + const npmCmd = npmCommand || getBundledBinary("node", "npm"); console.log(` Building stack...`); try { - runCommand(npmCmd, ["run", "build"], { + runBuildCommand(npmCmd, ["run", "build"], { cwd: project.root, stdio: verbose ? "inherit" : "pipe" }); @@ -30191,11 +30211,11 @@ async function runStack(id, options = {}) { const startTime = Date.now(); const packagePath = getPackagePath(id); const manifestPath = import_path12.default.join(packagePath, "manifest.json"); - const { default: fs52 } = await import("fs"); - if (!fs52.existsSync(manifestPath)) { + const { default: fs54 } = await import("fs"); + if (!fs54.existsSync(manifestPath)) { throw new Error(`Stack manifest not found: ${id}`); } - const manifest = JSON.parse(fs52.readFileSync(manifestPath, "utf-8")); + const manifest = JSON.parse(fs54.readFileSync(manifestPath, "utf-8")); const { command, args } = resolveCommandFromManifest(manifest, packagePath); const secrets = await getSecrets(manifest.requires?.secrets || []); const runEnv = buildStackRunEnv({ @@ -30229,7 +30249,7 @@ async function runStack(id, options = {}) { onStderr(redactSecrets(text, secrets)); } }); - return new Promise((resolve3, reject) => { + return new Promise((resolve4, reject) => { proc.on("error", (error) => { reject(error); }); @@ -30244,7 +30264,7 @@ async function runStack(id, options = {}) { if (onExit) { onExit(result); } - resolve3(result); + resolve4(result); }); }); } @@ -31166,7 +31186,7 @@ function secretsInfo() { console.log(" Same approach as AWS CLI, SSH, GitHub CLI."); } function promptSecret(prompt) { - return new Promise((resolve3) => { + return new Promise((resolve4) => { const rl = import_readline.default.createInterface({ input: process.stdin, output: process.stdout @@ -31182,7 +31202,7 @@ function promptSecret(prompt) { process.stdin.removeListener("data", onData); console.log(); rl.close(); - resolve3(input); + resolve4(input); } else if (char === "") { process.exit(0); } else if (char === "\x7F") { @@ -32348,7 +32368,12 @@ function createShim(shimPath, targetPath) { } // src/commands/update.js +var import_node_crypto5 = require("node:crypto"); +var import_node_fs18 = require("node:fs"); +var fs39 = __toESM(require("node:fs/promises"), 1); +var path38 = __toESM(require("path"), 1); init_src5(); +init_src(); init_src3(); var KNOWN_PACKAGE_KINDS = /* @__PURE__ */ new Set(["stack", "skill", "prompt", "workflow", "runtime", "binary", "agent", "npm"]); function rebuildToolIndex(options = {}) { @@ -32358,11 +32383,415 @@ function rebuildToolIndex(options = {}) { timeout: options.timeout }); } +async function resolveManagedPath(candidate, rootInput, options) { + const { candidateLabel, rootLabel, createRoot = false } = options; + if (typeof candidate !== "string" || candidate.trim() !== candidate || !candidate) { + throw new Error(`${candidateLabel} is required for transactional update`); + } + const root = path38.resolve(rootInput); + const targetPath = path38.resolve(candidate); + if (targetPath === root || !targetPath.startsWith(`${root}${path38.sep}`)) { + throw new Error(`Refusing to snapshot ${candidateLabel.toLowerCase()} outside the managed ${rootLabel}: ${candidate}`); + } + if (createRoot) { + await fs39.mkdir(root, { recursive: true }); + } + const rootStat = await fs39.lstat(root); + if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) { + throw new Error(`Managed ${rootLabel} must be a real directory: ${root}`); + } + const relative4 = path38.relative(root, targetPath); + let current = root; + for (const segment of relative4.split(path38.sep)) { + current = path38.join(current, segment); + try { + const stat = await fs39.lstat(current); + if (stat.isSymbolicLink()) { + throw new Error(`Refusing symlinked path within managed ${rootLabel}: ${current}`); + } + } catch (error) { + if (error.code === "ENOENT") break; + throw error; + } + } + return { root, targetPath }; +} +function resolveManagedStackPath(stackPath, stacksRoot = PATHS.stacks, options = {}) { + return resolveManagedPath(stackPath, stacksRoot, { + candidateLabel: "Installed stack path", + rootLabel: "stack root", + ...options + }); +} +function resolveManagedLockfilePath(lockfilePath, locksRoot = PATHS.locks, options = {}) { + return resolveManagedPath(lockfilePath, locksRoot, { + candidateLabel: "Stack lockfile path", + rootLabel: "lock root", + ...options + }); +} +function resolveManagedStackStatePath(stateRoot, stateStacksRoot = path38.join(PATHS.home, "state", "stacks"), options = {}) { + return resolveManagedPath(stateRoot, stateStacksRoot, { + candidateLabel: "Stack state path", + rootLabel: "stack state root", + ...options + }); +} +async function buildTreeManifest(rootPath, prefix = "") { + const entries = []; + async function visit(currentPath, relativePath) { + let stat; + try { + stat = await fs39.lstat(currentPath); + } catch (error) { + if (error.code === "ENOENT" && relativePath === prefix) return; + throw error; + } + const manifestPath = relativePath || "."; + if (stat.isSymbolicLink()) { + entries.push([manifestPath, "symlink", await fs39.readlink(currentPath)]); + return; + } + if (stat.isDirectory()) { + entries.push([manifestPath, "directory", ""]); + const names = await fs39.readdir(currentPath); + names.sort(); + for (const name of names) { + const childRelative = relativePath ? path38.join(relativePath, name) : name; + await visit(path38.join(currentPath, name), childRelative); + } + return; + } + if (stat.isFile()) { + const digest = (0, import_node_crypto5.createHash)("sha256").update(await fs39.readFile(currentPath)).digest("hex"); + entries.push([manifestPath, "file", digest]); + return; + } + throw new Error(`Unsupported state entry type: ${currentPath}`); + } + await visit(rootPath, prefix); + return entries.sort((left, right) => left[0].localeCompare(right[0])); +} +function mergeExpectedStateManifest(initialManifest, migratedRunsManifest) { + if (migratedRunsManifest.length === 0) return initialManifest; + const byPath = new Map(initialManifest.map((entry) => [entry[0], entry])); + if (!byPath.has(".")) byPath.set(".", [".", "directory", ""]); + for (const entry of migratedRunsManifest) { + const existing = byPath.get(entry[0]); + if (existing) { + if (existing[1] !== "directory" || entry[1] !== "directory") return null; + continue; + } + byPath.set(entry[0], entry); + } + return [...byPath.values()].sort((left, right) => left[0].localeCompare(right[0])); +} +function validTreeManifest(value) { + return Array.isArray(value) && value.every((entry) => Array.isArray(entry) && entry.length === 3 && entry.every((part) => typeof part === "string")); +} +function treeManifestsEqual(left, right) { + return JSON.stringify(left) === JSON.stringify(right); +} +async function assertSnapshotComponent(componentPath, type, label) { + let stat; + try { + stat = await fs39.lstat(componentPath); + } catch (error) { + if (error.code === "ENOENT") throw new Error(`Missing ${label}: ${componentPath}`); + throw error; + } + const validType = type === "directory" ? stat.isDirectory() : stat.isFile(); + if (!validType || stat.isSymbolicLink()) { + throw new Error(`Invalid ${label}: ${componentPath}`); + } +} +async function copyPathWithoutOverwrite(sourcePath, destinationPath, label) { + const sourceStat = await fs39.lstat(sourcePath); + if (sourceStat.isSymbolicLink()) { + throw new Error(`Refusing to copy symlinked ${label}: ${sourcePath}`); + } + await fs39.mkdir(path38.dirname(destinationPath), { recursive: true }); + const expectedManifest = await buildTreeManifest(sourcePath); + if (sourceStat.isFile()) { + await fs39.copyFile(sourcePath, destinationPath, import_node_fs18.constants.COPYFILE_EXCL); + } else if (sourceStat.isDirectory()) { + await fs39.cp(sourcePath, destinationPath, { + errorOnExist: true, + force: false, + preserveTimestamps: true, + recursive: true + }); + } else { + throw new Error(`Unsupported ${label} type: ${sourcePath}`); + } + const copiedManifest = await buildTreeManifest(destinationPath); + if (!treeManifestsEqual(copiedManifest, expectedManifest)) { + throw new Error( + `Concurrent ${label} mutation detected at ${destinationPath}; exact source retained at ${sourcePath}` + ); + } +} +async function validateStackUpdateSnapshot(snapshot, options = {}) { + if (!snapshot || typeof snapshot !== "object") { + throw new Error("Invalid stack update snapshot"); + } + const { root, targetPath } = await resolveManagedStackPath( + snapshot.targetPath, + options.stacksRoot || PATHS.stacks + ); + const { targetPath: lockfilePath } = await resolveManagedLockfilePath( + snapshot.lockfilePath, + snapshot.locksRoot || PATHS.locks + ); + const { targetPath: stateRoot } = await resolveManagedStackStatePath( + snapshot.stateRoot, + options.stateStacksRoot || snapshot.stateStacksRoot || path38.join(PATHS.home, "state", "stacks") + ); + const backupRoot = path38.resolve(String(snapshot.backupRoot || "")); + const snapshotPath = path38.resolve(String(snapshot.snapshotPath || "")); + const lockfileSnapshotPath = path38.resolve(String(snapshot.lockfileSnapshotPath || "")); + const stateSnapshotPath = path38.resolve(String(snapshot.stateSnapshotPath || "")); + const expectedPrefix = `.${path38.basename(targetPath)}.update-backup-`; + if (path38.dirname(backupRoot) !== root || !path38.basename(backupRoot).startsWith(expectedPrefix) || snapshotPath !== path38.join(backupRoot, "snapshot") || lockfileSnapshotPath !== path38.join(backupRoot, "lockfile") || stateSnapshotPath !== path38.join(backupRoot, "state")) { + throw new Error("Invalid stack update snapshot paths"); + } + await assertSnapshotComponent(backupRoot, "directory", "stack update backup root"); + if (!validTreeManifest(snapshot.stateInitialManifest)) { + throw new Error("Invalid initial state manifest in stack update snapshot"); + } + if (snapshot.stateExpectedManifest !== null && !validTreeManifest(snapshot.stateExpectedManifest)) { + throw new Error("Invalid expected state manifest in stack update snapshot"); + } + return { + backupRoot, + lockfileExisted: snapshot.lockfileExisted === true, + lockfilePath, + lockfileSnapshotPath, + snapshotPath, + stateRoot, + stateRootExisted: snapshot.stateRootExisted === true, + stateExpectedManifest: snapshot.stateExpectedManifest, + stateInitialManifest: snapshot.stateInitialManifest, + stateSnapshotPath, + targetPath + }; +} +async function createStackUpdateSnapshot(stackPath, options = {}) { + const { root, targetPath } = await resolveManagedStackPath(stackPath, options.stacksRoot); + const locksRoot = path38.resolve(options.locksRoot || PATHS.locks); + const { targetPath: lockfilePath } = await resolveManagedLockfilePath( + options.lockfilePath, + locksRoot, + { createRoot: true } + ); + const stateStacksRoot = path38.resolve( + options.stateStacksRoot || (options.stacksRoot ? path38.join(path38.dirname(root), "state", "stacks") : path38.join(PATHS.home, "state", "stacks")) + ); + const { targetPath: stateRoot } = await resolveManagedStackStatePath( + options.stateRoot || path38.join(stateStacksRoot, path38.basename(targetPath)), + stateStacksRoot, + { createRoot: true } + ); + const stackStat = await fs39.lstat(targetPath); + if (!stackStat.isDirectory() || stackStat.isSymbolicLink()) { + throw new Error(`Installed stack path must be a real directory: ${stackPath}`); + } + const backupRoot = await fs39.mkdtemp( + path38.join(root, `.${path38.basename(targetPath)}.update-backup-`) + ); + const snapshotPath = path38.join(backupRoot, "snapshot"); + const lockfileSnapshotPath = path38.join(backupRoot, "lockfile"); + const stateSnapshotPath = path38.join(backupRoot, "state"); + let lockfileExisted = false; + let stateRootExisted = false; + const stateInitialManifest = await buildTreeManifest(stateRoot); + const migratedRunsManifest = await buildTreeManifest(path38.join(targetPath, "runs"), "runs"); + const stateExpectedManifest = mergeExpectedStateManifest( + stateInitialManifest, + migratedRunsManifest + ); + try { + await fs39.chmod(backupRoot, 448); + await fs39.cp(targetPath, snapshotPath, { + errorOnExist: true, + force: false, + preserveTimestamps: true, + recursive: true + }); + try { + const lockfileStat = await fs39.lstat(lockfilePath); + if (!lockfileStat.isFile() || lockfileStat.isSymbolicLink()) { + throw new Error(`Stack lockfile path must be a real file: ${lockfilePath}`); + } + await fs39.copyFile(lockfilePath, lockfileSnapshotPath); + lockfileExisted = true; + } catch (error) { + if (error.code !== "ENOENT") throw error; + } + try { + const stateStat = await fs39.lstat(stateRoot); + if (!stateStat.isDirectory() || stateStat.isSymbolicLink()) { + throw new Error(`Stack state path must be a real directory: ${stateRoot}`); + } + await fs39.cp(stateRoot, stateSnapshotPath, { + errorOnExist: true, + force: false, + preserveTimestamps: true, + recursive: true + }); + stateRootExisted = true; + } catch (error) { + if (error.code !== "ENOENT") throw error; + } + } catch (error) { + await fs39.rm(backupRoot, { force: true, recursive: true }); + throw error; + } + return { + backupRoot, + lockfileExisted, + lockfilePath, + lockfileSnapshotPath, + locksRoot, + snapshotPath, + stateRoot, + stateRootExisted, + stateExpectedManifest, + stateInitialManifest, + stateSnapshotPath, + stateStacksRoot, + targetPath + }; +} +async function restoreStackUpdateSnapshot(snapshot, options = {}) { + const { + backupRoot, + lockfileExisted, + lockfilePath, + lockfileSnapshotPath, + snapshotPath, + stateRoot, + stateRootExisted, + stateExpectedManifest, + stateInitialManifest, + stateSnapshotPath, + targetPath + } = await validateStackUpdateSnapshot(snapshot, options); + await assertSnapshotComponent(snapshotPath, "directory", "stack snapshot"); + if (lockfileExisted) { + await assertSnapshotComponent(lockfileSnapshotPath, "file", "lockfile snapshot"); + } + if (stateRootExisted) { + await assertSnapshotComponent(stateSnapshotPath, "directory", "state snapshot"); + } + const components = [ + { + currentPath: stateRoot, + existedBefore: stateRootExisted, + label: "state", + snapshotPath: stateSnapshotPath + }, + { + currentPath: targetPath, + existedBefore: true, + label: "install", + snapshotPath + }, + { + currentPath: lockfilePath, + existedBefore: lockfileExisted, + label: "lockfile", + snapshotPath: lockfileSnapshotPath + } + ]; + const stateComponent = components[0]; + for (const component of components) { + component.stagedPath = path38.join(backupRoot, `failed-${component.label}`); + component.staged = false; + component.promoted = false; + } + try { + for (const component of components) { + try { + const currentStat = await fs39.lstat(component.currentPath); + if (currentStat.isSymbolicLink()) { + throw new Error(`Refusing to stage symlinked ${component.label}: ${component.currentPath}`); + } + await fs39.rename(component.currentPath, component.stagedPath); + component.staged = true; + } catch (error) { + if (error.code !== "ENOENT") throw error; + } + } + const stagedStateManifest = stateComponent.staged ? await buildTreeManifest(stateComponent.stagedPath) : []; + const stateMatchesInitial = treeManifestsEqual(stagedStateManifest, stateInitialManifest); + const stateMatchesExpected = stateExpectedManifest !== null && treeManifestsEqual(stagedStateManifest, stateExpectedManifest); + if (!stateMatchesInitial && !stateMatchesExpected) { + throw new Error(`Stack state changed during update; refusing to rewind: ${stateRoot}`); + } + for (const component of components) { + if (!component.existedBefore) continue; + await fs39.mkdir(path38.dirname(component.currentPath), { recursive: true }); + await fs39.rename(component.snapshotPath, component.currentPath); + component.promoted = true; + } + } catch (error) { + const compensationErrors = []; + for (const component of [...components].reverse()) { + if (component.promoted) { + try { + await copyPathWithoutOverwrite( + component.currentPath, + component.snapshotPath, + `accepted ${component.label}` + ); + } catch (compensationError) { + compensationErrors.push(compensationError.message); + } + compensationErrors.push( + `Rollback could not atomically restore the failed ${component.label}; accepted data remains at ${component.currentPath} and failed data remains at ${component.stagedPath}` + ); + continue; + } + if (component.staged) { + try { + await copyPathWithoutOverwrite( + component.stagedPath, + component.currentPath, + `failed ${component.label}` + ); + } catch (compensationError) { + compensationErrors.push(compensationError.message); + } + } + } + if (compensationErrors.length > 0) { + throw new Error( + `${error.message}; rollback compensation failed: ${compensationErrors.join("; ")}`, + { cause: error } + ); + } + throw error; + } + await fs39.rm(backupRoot, { force: true, recursive: true }); +} +async function discardStackUpdateSnapshot(snapshot, options = {}) { + const { backupRoot } = await validateStackUpdateSnapshot(snapshot, options); + await fs39.rm(backupRoot, { force: true, recursive: true }); +} var defaultDependencies = { fetchIndex, listInstalled, resolvePackage, updatePackage, + getPackageLockfilePath: getLockfilePath, + createStackUpdateSnapshot, + restoreStackUpdateSnapshot, + discardStackUpdateSnapshot, + loadStackManifest: loadManifest, + buildStack: buildStackIfNeeded, + validateStack: validateStackEntryPoint, + registerStack: addStack, rebuildToolIndex, log: console.log, error: console.error @@ -32467,15 +32896,67 @@ function logSkillProjectionFailures(skillProjection, deps) { } async function updateOnePackage(pkg, flags, deps) { deps.log(`Updating ${pkg.id}...`); - const result = await deps.updatePackage(pkg.id, { - preserveState: shouldPreserveInstallState(flags) - }); - if (!result?.success) { - throw new Error(result?.error || `Failed to update ${pkg.id}`); + const kind = pkg.kind || packageKindFromId(pkg.id); + const snapshot = kind === "stack" ? await deps.createStackUpdateSnapshot(pkg.path, { + lockfilePath: deps.getPackageLockfilePath(pkg.id) + }) : null; + let result; + try { + result = await deps.updatePackage(pkg.id, { + preserveState: shouldPreserveInstallState(flags) + }); + if (!result?.success) { + throw new Error(result?.error || `Failed to update ${pkg.id}`); + } + if (kind === "stack") { + if (path38.resolve(result.path) !== path38.resolve(snapshot.targetPath)) { + throw new Error(`Updated stack path changed unexpectedly for ${pkg.id}`); + } + const manifest = await deps.loadStackManifest(result.path); + if (!manifest) { + throw new Error(`Stack manifest not found after updating ${pkg.id}`); + } + await deps.buildStack(result.path, manifest, { + force: true, + verbose: Boolean(flags.verbose) + }); + const validation = deps.validateStack(result.path, manifest); + if (!validation.valid) { + throw new Error(`Stack validation failed: ${validation.error}`); + } + deps.registerStack(pkg.id, { + path: result.path, + runtime: getStackRuntime(manifest), + command: getStackCommand(manifest), + secrets: getManifestSecrets(manifest), + version: manifest.version + }); + } + } catch (error) { + if (snapshot) { + try { + await deps.restoreStackUpdateSnapshot(snapshot); + } catch (rollbackError) { + throw new Error( + `${error.message}; stack rollback failed: ${rollbackError.message}`, + { cause: error } + ); + } + } + throw error; + } + if (snapshot) { + try { + await deps.discardStackUpdateSnapshot(snapshot); + } catch (cleanupError) { + deps.error( + ` ! ${pkg.id}: update applied, but snapshot cleanup failed: ${cleanupError.message}` + ); + } } return { id: pkg.id, - kind: pkg.kind || packageKindFromId(pkg.id), + kind, result }; } @@ -32591,6 +33072,9 @@ async function runUpdate(args = [], flags = {}, deps = defaultDependencies) { const updated = await updateOnePackage(pkg, flags, deps); updatedPackages.push(updated); } catch (error) { + if (pkg.id === target.id) { + throw error; + } failedPackages.push({ id: pkg.id, error: error.message }); deps.error(` x ${pkg.id}: ${error.message}`); } @@ -32685,10 +33169,11 @@ async function cmdUpdate(args, flags, dependencies = {}) { } // src/commands/which.js -var fs39 = __toESM(require("fs/promises"), 1); -var path38 = __toESM(require("path"), 1); +var fs40 = __toESM(require("fs/promises"), 1); +var path39 = __toESM(require("path"), 1); init_src5(); init_src(); +init_src4(); async function cmdWhich(args, flags) { const stackId = args[0]; if (!stackId) { @@ -32715,7 +33200,7 @@ Installed stacks:`); } const stackPath = stack.path; const runtimeInfo = await detectRuntime(stackPath); - const authStatus = await checkAuth(stackPath, runtimeInfo.runtime); + const authStatus = await checkAuth(stackPath, runtimeInfo.runtime, { stack }); const isRunning = checkIfRunning(stack.name || stack.id.replace("stack:", "")); console.log(""); console.log("\u2550".repeat(60)); @@ -32767,7 +33252,7 @@ Installed stacks:`); if (runtimeInfo.entry) { console.log(""); console.log("Run MCP server directly:"); - const entryPath = path38.join(stackPath, runtimeInfo.entry); + const entryPath = path39.join(stackPath, runtimeInfo.entry); if (runtimeInfo.runtime === "node") { console.log(` echo '{"jsonrpc":"2.0","method":"tools/list","id":1}' | node ${entryPath}`); } else if (runtimeInfo.runtime === "python") { @@ -32785,32 +33270,32 @@ Installed stacks:`); } async function detectRuntime(stackPath) { const layouts = [ - { runtime: "node", runtimePath: path38.join(stackPath, "node"), entryPrefix: "node/", explicit: true }, - { runtime: "python", runtimePath: path38.join(stackPath, "python"), entryPrefix: "python/", explicit: true }, + { runtime: "node", runtimePath: path39.join(stackPath, "node"), entryPrefix: "node/", explicit: true }, + { runtime: "python", runtimePath: path39.join(stackPath, "python"), entryPrefix: "python/", explicit: true }, { runtime: "node", runtimePath: stackPath, entryPrefix: "", explicit: false }, { runtime: "python", runtimePath: stackPath, entryPrefix: "", explicit: false } ]; for (const { runtime, runtimePath, entryPrefix, explicit } of layouts) { try { - await fs39.access(runtimePath); + await fs40.access(runtimePath); if (runtime === "node") { - const distEntry = path38.join(runtimePath, "dist", "index.js"); - const srcEntry = path38.join(runtimePath, "src", "index.ts"); + const distEntry = path39.join(runtimePath, "dist", "index.js"); + const srcEntry = path39.join(runtimePath, "src", "index.ts"); try { - await fs39.access(distEntry); + await fs40.access(distEntry); return { runtime: "node", entry: `${entryPrefix}dist/index.js` }; } catch { try { - await fs39.access(srcEntry); + await fs40.access(srcEntry); return { runtime: "node", entry: `${entryPrefix}src/index.ts` }; } catch { if (explicit) return { runtime: "node", entry: null }; } } } else if (runtime === "python") { - const entry = path38.join(runtimePath, "src", "index.py"); + const entry = path39.join(runtimePath, "src", "index.py"); try { - await fs39.access(entry); + await fs40.access(entry); return { runtime: "python", entry: `${entryPrefix}src/index.py` }; } catch { if (explicit) return { runtime: "python", entry: null }; @@ -32830,18 +33315,18 @@ async function checkAuth(stackPath, runtime, options = {}) { if (!rootPath || checkedRoots.has(rootPath)) return; checkedRoots.add(rootPath); try { - await fs39.access(path38.join(rootPath, "token.json")); + await fs40.access(path39.join(rootPath, "token.json")); authFiles.push(labelPrefix ? `${labelPrefix}/token.json` : "token.json"); configured = true; } catch { - const accountsPath = path38.join(rootPath, "accounts"); + const accountsPath = path39.join(rootPath, "accounts"); try { - const accounts = await fs39.readdir(accountsPath); + const accounts = await fs40.readdir(accountsPath); for (const account of accounts) { if (account.startsWith(".")) continue; - const accountTokenPath = path38.join(accountsPath, account, "token.json"); + const accountTokenPath = path39.join(accountsPath, account, "token.json"); try { - await fs39.access(accountTokenPath); + await fs40.access(accountTokenPath); const label = labelPrefix ? `${labelPrefix}/accounts/${account}/token.json` : `accounts/${account}/token.json`; authFiles.push(label); configured = true; @@ -32853,30 +33338,77 @@ async function checkAuth(stackPath, runtime, options = {}) { } } if (runtime === "node" || runtime === "python") { - await scanAuthRoot(path38.join(stackPath, runtime), runtime); + await scanAuthRoot(path39.join(stackPath, runtime), runtime); await scanAuthRoot(stackPath, ""); } - const stackName = options.stackName || path38.basename(stackPath); + const stackName = options.stackName || path39.basename(stackPath); const rudiHome = options.rudiHome || PATHS.home; await scanAuthRoot( - path38.join(rudiHome, "state", "stacks", stackName), + path39.join(rudiHome, "state", "stacks", stackName), `state/stacks/${stackName}` ); - const envPath = path38.join(stackPath, ".env"); + const envCredentialNames = /* @__PURE__ */ new Set(); + const envPath = path39.join(stackPath, ".env"); try { - const envContent = await fs39.readFile(envPath, "utf-8"); - const hasValues = envContent.split("\n").some((line) => { + const envContent = await fs40.readFile(envPath, "utf-8"); + for (const line of envContent.split("\n")) { const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith("#")) return false; - const [key, value] = trimmed.split("="); - return value && value.trim() && !value.includes("YOUR_") && !value.includes("your_"); - }); - if (hasValues) { - authFiles.push(".env"); - configured = true; + if (!trimmed || trimmed.startsWith("#")) continue; + const separatorIndex = trimmed.indexOf("="); + if (separatorIndex <= 0) continue; + const name = trimmed.slice(0, separatorIndex).trim(); + let value = trimmed.slice(separatorIndex + 1).trim(); + const quotedValue = value.match(/^(["'])(.*?)\1(?:\s*#.*)?$/); + if (quotedValue) { + value = quotedValue[2].trim(); + } else { + value = value.replace(/\s+#.*$/, "").trim(); + } + if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(name) && value && !value.includes("YOUR_") && !value.includes("your_")) { + envCredentialNames.add(name); + } } } catch { } + const manifestSecrets = options.stack?.requires?.secrets || options.stack?.secrets || []; + if (Array.isArray(manifestSecrets) && manifestSecrets.length > 0) { + const hasSecret2 = options.hasSecret || hasSecret; + let requiredCount = 0; + let requiredPresent = 0; + let presentCount = 0; + let envCredentialPresent = false; + for (const [index, secret] of manifestSecrets.entries()) { + const rawName = typeof secret === "string" ? secret : secret?.name || secret?.key; + if (typeof rawName !== "string" || !rawName || rawName !== rawName.trim()) { + throw new Error(`Invalid stack secret name at index ${index}`); + } + const name = rawName; + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) { + throw new Error(`Invalid stack secret name at index ${index}`); + } + const required = typeof secret !== "object" || secret === null || secret.required !== false; + if (required) requiredCount += 1; + const storedCredentialPresent = await hasSecret2(name); + const localEnvCredentialPresent = envCredentialNames.has(name); + if (storedCredentialPresent || localEnvCredentialPresent) { + presentCount += 1; + if (required) requiredPresent += 1; + if (storedCredentialPresent) { + authFiles.push(`RUDI secrets (${name})`); + } + if (localEnvCredentialPresent) { + envCredentialPresent = true; + } + } + } + if (requiredCount > 0 && requiredPresent === requiredCount || requiredCount === 0 && presentCount === manifestSecrets.length) { + configured = true; + if (envCredentialPresent) authFiles.push(".env"); + } + } else if (envCredentialNames.size > 0) { + authFiles.push(".env"); + configured = true; + } if (configured) { return { configured: true, @@ -32911,8 +33443,8 @@ function checkIfRunning(stackName, options = {}) { } // src/commands/auth.js -var fs40 = __toESM(require("fs/promises"), 1); -var path39 = __toESM(require("path"), 1); +var fs41 = __toESM(require("fs/promises"), 1); +var path40 = __toESM(require("path"), 1); var import_child_process8 = require("child_process"); init_src5(); init_src4(); @@ -32926,49 +33458,49 @@ async function findAvailablePort(basePort = 3456) { throw new Error(`No available ports found in range ${basePort}-${basePort + 10}`); } function isPortAvailable(port) { - return new Promise((resolve3) => { + return new Promise((resolve4) => { const server = net.createServer(); server.once("error", (err) => { if (err.code === "EADDRINUSE") { - resolve3(false); + resolve4(false); } else { - resolve3(false); + resolve4(false); } }); server.once("listening", () => { server.close(); - resolve3(true); + resolve4(true); }); server.listen(port); }); } async function detectRuntime2(stackPath) { const layouts = [ - { runtime: "node", runtimePath: path39.join(stackPath, "node") }, + { runtime: "node", runtimePath: path40.join(stackPath, "node") }, { runtime: "node", runtimePath: stackPath }, - { runtime: "python", runtimePath: path39.join(stackPath, "python") }, + { runtime: "python", runtimePath: path40.join(stackPath, "python") }, { runtime: "python", runtimePath: stackPath } ]; for (const { runtime, runtimePath } of layouts) { try { - await fs40.access(runtimePath); + await fs41.access(runtimePath); if (runtime === "node") { - const authTs = path39.join(runtimePath, "src", "auth.ts"); - const authJs = path39.join(runtimePath, "dist", "auth.js"); + const authTs = path40.join(runtimePath, "src", "auth.ts"); + const authJs = path40.join(runtimePath, "dist", "auth.js"); try { - await fs40.access(authTs); + await fs41.access(authTs); return { runtime: "node", authScript: authTs, useTsx: true }; } catch { try { - await fs40.access(authJs); + await fs41.access(authJs); return { runtime: "node", authScript: authJs, useTsx: false }; } catch { } } } else if (runtime === "python") { - const authPy = path39.join(runtimePath, "src", "auth.py"); + const authPy = path40.join(runtimePath, "src", "auth.py"); try { - await fs40.access(authPy); + await fs41.access(authPy); return { runtime: "python", authScript: authPy, useTsx: false }; } catch { } @@ -33083,7 +33615,7 @@ function runAuthSubprocess(plan, options = {}) { function getTempAuthScriptPath(authScript, useTsx) { const safeAuthScript = requireSubprocessArg(authScript, "auth script path"); const tempExt = useTsx ? ".ts" : ".mjs"; - return path39.join(path39.dirname(safeAuthScript), `auth-temp${tempExt}`); + return path40.join(path40.dirname(safeAuthScript), `auth-temp${tempExt}`); } async function cmdAuth(args, flags) { const stackId = args[0]; @@ -33124,14 +33656,14 @@ Installed stacks:`); const port = await findAvailablePort(3456); console.log(`Using port: ${port}`); console.log(""); - const cwd = path39.dirname(authInfo.authScript); + const cwd = path40.dirname(authInfo.authScript); if (authInfo.runtime === "node") { - const distAuth = path39.join(cwd, "..", "dist", "auth.js"); + const distAuth = path40.join(cwd, "..", "dist", "auth.js"); let useBuiltInPort = false; let tempAuthScript = null; try { - await fs40.access(distAuth); - const distContent = await fs40.readFile(distAuth, "utf-8"); + await fs41.access(distAuth); + const distContent = await fs41.readFile(distAuth, "utf-8"); if (distContent.includes("findAvailablePort")) { console.log("Using compiled authentication script..."); useBuiltInPort = true; @@ -33139,10 +33671,10 @@ Installed stacks:`); } catch { } if (!useBuiltInPort) { - const authContent = await fs40.readFile(authInfo.authScript, "utf-8"); + const authContent = await fs41.readFile(authInfo.authScript, "utf-8"); tempAuthScript = getTempAuthScriptPath(authInfo.authScript, authInfo.useTsx); const modifiedContent = authContent.replace(/localhost:3456/g, `localhost:${port}`).replace(/server\.listen\(3456/g, `server.listen(${port}`); - await fs40.writeFile(tempAuthScript, modifiedContent); + await fs41.writeFile(tempAuthScript, modifiedContent); } console.log("Starting OAuth flow..."); console.log(""); @@ -33159,12 +33691,12 @@ Installed stacks:`); env: authEnv }); if (tempAuthScript) { - await fs40.unlink(tempAuthScript); + await fs41.unlink(tempAuthScript); } } catch (error) { if (tempAuthScript) { try { - await fs40.unlink(tempAuthScript); + await fs41.unlink(tempAuthScript); } catch { } } @@ -33200,22 +33732,22 @@ Installed stacks:`); } // src/commands/mcp.js -var fs41 = __toESM(require("fs"), 1); -var path40 = __toESM(require("path"), 1); +var fs42 = __toESM(require("fs"), 1); +var path41 = __toESM(require("path"), 1); var import_child_process9 = require("child_process"); init_src(); init_src4(); function getBundledRuntime(runtime) { const platform = process.platform; if (runtime === "node") { - const nodePath = platform === "win32" ? path40.join(PATHS.runtimes, "node", "node.exe") : path40.join(PATHS.runtimes, "node", "bin", "node"); - if (fs41.existsSync(nodePath)) { + const nodePath = platform === "win32" ? path41.join(PATHS.runtimes, "node", "node.exe") : path41.join(PATHS.runtimes, "node", "bin", "node"); + if (fs42.existsSync(nodePath)) { return nodePath; } } if (runtime === "python") { - const pythonPath = platform === "win32" ? path40.join(PATHS.runtimes, "python", "python.exe") : path40.join(PATHS.runtimes, "python", "bin", "python3"); - if (fs41.existsSync(pythonPath)) { + const pythonPath = platform === "win32" ? path41.join(PATHS.runtimes, "python", "python.exe") : path41.join(PATHS.runtimes, "python", "bin", "python3"); + if (fs42.existsSync(pythonPath)) { return pythonPath; } } @@ -33223,18 +33755,18 @@ function getBundledRuntime(runtime) { } function getBundledNpx() { const platform = process.platform; - const npxPath = platform === "win32" ? path40.join(PATHS.runtimes, "node", "npx.cmd") : path40.join(PATHS.runtimes, "node", "bin", "npx"); - if (fs41.existsSync(npxPath)) { + const npxPath = platform === "win32" ? path41.join(PATHS.runtimes, "node", "npx.cmd") : path41.join(PATHS.runtimes, "node", "bin", "npx"); + if (fs42.existsSync(npxPath)) { return npxPath; } return null; } function loadManifest2(stackPath) { - const manifestPath = path40.join(stackPath, "manifest.json"); - if (!fs41.existsSync(manifestPath)) { + const manifestPath = path41.join(stackPath, "manifest.json"); + if (!fs42.existsSync(manifestPath)) { return null; } - return JSON.parse(fs41.readFileSync(manifestPath, "utf-8")); + return JSON.parse(fs42.readFileSync(manifestPath, "utf-8")); } function getRequiredSecrets(manifest) { const secrets = manifest?.requires?.secrets || manifest?.secrets || []; @@ -33267,8 +33799,8 @@ async function cmdMcp(args, flags) { console.error("Example: rudi mcp slack"); process.exit(1); } - const stackPath = path40.join(PATHS.stacks, stackName); - if (!fs41.existsSync(stackPath)) { + const stackPath = path41.join(PATHS.stacks, stackName); + if (!fs42.existsSync(stackPath)) { console.error(`Stack not found: ${stackName}`); console.error(`Expected at: ${stackPath}`); console.error(""); @@ -33317,22 +33849,22 @@ async function cmdMcp(args, flags) { } return part; } - if (part.startsWith("./") || part.startsWith("../") || !path40.isAbsolute(part)) { - const resolved = path40.join(stackPath, part); - if (fs41.existsSync(resolved)) { + if (part.startsWith("./") || part.startsWith("../") || !path41.isAbsolute(part)) { + const resolved = path41.join(stackPath, part); + if (fs42.existsSync(resolved)) { return resolved; } } return part; }); const [cmd, ...cmdArgs] = resolvedCommand; - const bundledNodeBin = path40.join(PATHS.runtimes, "node", "bin"); - const bundledPythonBin = path40.join(PATHS.runtimes, "python", "bin"); - if (fs41.existsSync(bundledNodeBin) || fs41.existsSync(bundledPythonBin)) { + const bundledNodeBin = path41.join(PATHS.runtimes, "node", "bin"); + const bundledPythonBin = path41.join(PATHS.runtimes, "python", "bin"); + if (fs42.existsSync(bundledNodeBin) || fs42.existsSync(bundledPythonBin)) { const runtimePaths = []; - if (fs41.existsSync(bundledNodeBin)) runtimePaths.push(bundledNodeBin); - if (fs41.existsSync(bundledPythonBin)) runtimePaths.push(bundledPythonBin); - env.PATH = runtimePaths.join(path40.delimiter) + path40.delimiter + (env.PATH || ""); + if (fs42.existsSync(bundledNodeBin)) runtimePaths.push(bundledNodeBin); + if (fs42.existsSync(bundledPythonBin)) runtimePaths.push(bundledPythonBin); + env.PATH = runtimePaths.join(path41.delimiter) + path41.delimiter + (env.PATH || ""); } if (flags.debug) { console.error(`[rudi mcp] Stack: ${stackName}`); @@ -33362,47 +33894,47 @@ async function cmdMcp(args, flags) { } // src/commands/integrate.js -var fs42 = __toESM(require("fs"), 1); -var path41 = __toESM(require("path"), 1); +var fs43 = __toESM(require("fs"), 1); +var path42 = __toESM(require("path"), 1); var import_os8 = __toESM(require("os"), 1); init_src(); var HOME2 = import_os8.default.homedir(); -var ROUTER_SHIM_PATH = path41.join(PATHS.bins, "rudi-router"); -var LEGACY_ROUTER_SHIM_PATH = path41.join(PATHS.home, "shims", "rudi-router"); +var ROUTER_SHIM_PATH = path42.join(PATHS.bins, "rudi-router"); +var LEGACY_ROUTER_SHIM_PATH = path42.join(PATHS.home, "shims", "rudi-router"); function checkRouterShim() { - if (fs42.existsSync(ROUTER_SHIM_PATH)) return ROUTER_SHIM_PATH; - if (fs42.existsSync(LEGACY_ROUTER_SHIM_PATH)) return LEGACY_ROUTER_SHIM_PATH; + if (fs43.existsSync(ROUTER_SHIM_PATH)) return ROUTER_SHIM_PATH; + if (fs43.existsSync(LEGACY_ROUTER_SHIM_PATH)) return LEGACY_ROUTER_SHIM_PATH; throw new Error( `Router shim not found at ${ROUTER_SHIM_PATH} Run: rudi shims rebuild` ); } function backupConfig(configPath) { - if (!fs42.existsSync(configPath)) return null; + if (!fs43.existsSync(configPath)) return null; const backupPath = configPath + ".backup." + Date.now(); - fs42.copyFileSync(configPath, backupPath); + fs43.copyFileSync(configPath, backupPath); return backupPath; } function readJsonConfig(configPath) { - if (!fs42.existsSync(configPath)) { + if (!fs43.existsSync(configPath)) { return {}; } try { - return JSON.parse(fs42.readFileSync(configPath, "utf-8")); + return JSON.parse(fs43.readFileSync(configPath, "utf-8")); } catch { return {}; } } function writeJsonConfig(configPath, config) { - const dir = path41.dirname(configPath); - if (!fs42.existsSync(dir)) { - fs42.mkdirSync(dir, { recursive: true }); + const dir = path42.dirname(configPath); + if (!fs43.existsSync(dir)) { + fs43.mkdirSync(dir, { recursive: true }); } - fs42.writeFileSync(configPath, JSON.stringify(config, null, 2)); + fs43.writeFileSync(configPath, JSON.stringify(config, null, 2)); } function getAgentTargetPath(agentConfig) { const configPath = findAgentConfig(agentConfig); - return configPath || path41.join(HOME2, agentConfig.paths[process.platform]?.[0] || agentConfig.paths.darwin[0]); + return configPath || path42.join(HOME2, agentConfig.paths[process.platform]?.[0] || agentConfig.paths.darwin[0]); } function tomlString(value) { return `"${String(value).replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`; @@ -33441,9 +33973,9 @@ function buildCodexRouterTomlBlock(routerPath) { ].join("\n"); } function patchCodexTomlRouter(content, routerPath, options = {}) { - const rudiMcpShimPath = options.rudiMcpShimPath || path41.join(PATHS.bins, "rudi-mcp"); - const legacyMcpShimPath = options.legacyMcpShimPath || path41.join(PATHS.home, "shims", "rudi-mcp"); - const rudiStacksPath = options.rudiStacksPath || path41.join(PATHS.home, "stacks"); + const rudiMcpShimPath = options.rudiMcpShimPath || path42.join(PATHS.bins, "rudi-mcp"); + const legacyMcpShimPath = options.legacyMcpShimPath || path42.join(PATHS.home, "shims", "rudi-mcp"); + const rudiStacksPath = options.rudiStacksPath || path42.join(PATHS.home, "stacks"); const blocks = splitTomlBlocks(content || ""); const removedEntries = []; const removedServers = /* @__PURE__ */ new Set(); @@ -33504,23 +34036,23 @@ async function integrateCodexAgent(agentConfig, targetPath, flags) { ${agentConfig.name}:`); console.log(` Config: ${targetPath}`); const routerPath = checkRouterShim(); - const existing = fs42.existsSync(targetPath) ? fs42.readFileSync(targetPath, "utf-8") : ""; + const existing = fs43.existsSync(targetPath) ? fs43.readFileSync(targetPath, "utf-8") : ""; const result = patchCodexTomlRouter(existing, routerPath); if (result.removed.length > 0) { console.log(` Removed old entries: ${result.removed.join(", ")}`); } if (result.action !== "none" || result.removed.length > 0) { - const dir = path41.dirname(targetPath); - if (!fs42.existsSync(dir)) { - fs42.mkdirSync(dir, { recursive: true }); + const dir = path42.dirname(targetPath); + if (!fs43.existsSync(dir)) { + fs43.mkdirSync(dir, { recursive: true }); } - if (fs42.existsSync(targetPath)) { + if (fs43.existsSync(targetPath)) { const backup = backupConfig(targetPath); if (backup && flags.verbose) { console.log(` Backup: ${backup}`); } } - fs42.writeFileSync(targetPath, result.content); + fs43.writeFileSync(targetPath, result.content); if (result.action !== "none") { console.log(` ${result.action === "added" ? "\u2713 Added" : "\u2713 Updated"} rudi router`); } @@ -33543,7 +34075,7 @@ ${agentConfig.name}:`); console.log(` Config: ${targetPath}`); if (agentId === "codex") { const routerPath = checkRouterShim(); - const existing = fs42.existsSync(targetPath) ? fs42.readFileSync(targetPath, "utf-8") : ""; + const existing = fs43.existsSync(targetPath) ? fs43.readFileSync(targetPath, "utf-8") : ""; const result = patchCodexTomlRouter(existing, routerPath); if (result.removed.length > 0) { console.log(` Would remove old entries: ${result.removed.join(", ")}`); @@ -33578,9 +34110,9 @@ ${agentConfig.name}:`); if (!config[key]) { config[key] = {}; } - const rudiMcpShimPath = path41.join(PATHS.bins, "rudi-mcp"); - const legacyMcpShimPath = path41.join(PATHS.home, "shims", "rudi-mcp"); - const rudiStacksPath = path41.join(PATHS.home, "stacks"); + const rudiMcpShimPath = path42.join(PATHS.bins, "rudi-mcp"); + const legacyMcpShimPath = path42.join(PATHS.home, "shims", "rudi-mcp"); + const rudiStacksPath = path42.join(PATHS.home, "stacks"); const removedEntries = []; for (const [serverName, serverConfig] of Object.entries(config[key])) { if (serverName === "rudi") continue; @@ -33619,7 +34151,7 @@ ${agentConfig.name}:`); action = "updated"; } if (action !== "none" || removedEntries.length > 0) { - if (fs42.existsSync(targetPath)) { + if (fs43.existsSync(targetPath)) { const backup = backupConfig(targetPath); if (backup && flags.verbose) { console.log(` Backup: ${backup}`); @@ -34996,6 +35528,98 @@ async function cmdStatus(args, flags) { init_src5(); var import_fs22 = __toESM(require("fs"), 1); var import_path20 = __toESM(require("path"), 1); + +// src/runtime-inspection.js +var import_node_fs19 = __toESM(require("node:fs"), 1); +var import_node_path17 = __toESM(require("node:path"), 1); +init_src(); +function isWithinRoot(rootPath, candidatePath) { + const relative4 = import_node_path17.default.relative(rootPath, candidatePath); + return relative4 === "" || !relative4.startsWith(`..${import_node_path17.default.sep}`) && relative4 !== ".." && !import_node_path17.default.isAbsolute(relative4); +} +function declaredRuntimeBins(manifest) { + if (Array.isArray(manifest?.bins)) { + return manifest.bins.map((name) => ({ name, relativePath: import_node_path17.default.join("bin", name) })); + } + if (manifest?.bins && typeof manifest.bins === "object") { + return Object.entries(manifest.bins).map(([name, descriptor]) => ({ + name, + relativePath: descriptor?.path || import_node_path17.default.join("bin", name) + })); + } + return []; +} +function inspectRuntimeInstall(packageId) { + const installRoot = getPackagePath(packageId); + const manifestPath = import_node_path17.default.join(installRoot, "manifest.json"); + const rootExists = import_node_fs19.default.existsSync(installRoot); + const resolvedInstallRoot = rootExists ? import_node_fs19.default.realpathSync(installRoot) : installRoot; + const manifestPresent = import_node_fs19.default.existsSync(manifestPath); + if (!manifestPresent) { + return { + binaries: [], + error: rootExists ? "Installed runtime manifest is missing" : null, + installRoot, + installed: false, + manifest: null, + manifestPresent: false, + rootExists + }; + } + try { + const manifest = JSON.parse(import_node_fs19.default.readFileSync(manifestPath, "utf8")); + if (manifest.id !== packageId) { + const actualId = Object.hasOwn(manifest, "id") ? JSON.stringify(manifest.id) : "(missing)"; + throw new Error(`Installed runtime manifest ID mismatch: expected ${packageId}, got ${actualId}`); + } + const binaries = declaredRuntimeBins(manifest).map(({ name, relativePath }) => { + if (typeof name !== "string" || !name || typeof relativePath !== "string" || !relativePath) { + throw new Error("Installed runtime manifest contains an invalid binary declaration"); + } + const binaryPath = import_node_path17.default.resolve(installRoot, relativePath); + if (!isWithinRoot(installRoot, binaryPath)) { + throw new Error(`Installed runtime binary escapes its package root: ${name}`); + } + if (!import_node_fs19.default.existsSync(binaryPath)) { + throw new Error(`Installed runtime binary is missing: ${name}`); + } + const resolvedPath = import_node_fs19.default.realpathSync(binaryPath); + if (!isWithinRoot(resolvedInstallRoot, resolvedPath)) { + throw new Error(`Installed runtime binary resolves outside its package root: ${name}`); + } + if (!import_node_fs19.default.statSync(resolvedPath).isFile()) { + throw new Error(`Installed runtime binary is not a regular file: ${name}`); + } + import_node_fs19.default.accessSync(resolvedPath, import_node_fs19.default.constants.X_OK); + return { name, path: binaryPath, resolvedPath }; + }); + if (binaries.length === 0) { + throw new Error("Installed runtime manifest declares no binaries"); + } + return { + binaries, + error: null, + installRoot, + installed: true, + manifest, + manifestPresent: true, + primaryBinary: binaries[0], + rootExists: true + }; + } catch (error) { + return { + binaries: [], + error: error.message, + installRoot, + installed: false, + manifest: null, + manifestPresent: true, + rootExists: true + }; + } +} + +// src/commands/check.js var KNOWN_AGENT_HOSTS = /* @__PURE__ */ new Set(["antigravity", "claude", "codex", "gemini", "google"]); function getVersion3(binaryPath, versionFlag = "--version") { try { @@ -35097,17 +35721,29 @@ async function cmdCheck(args, flags) { break; } case "runtime": { - const rudiPath = import_path20.default.join(PATHS.runtimes, name, "bin", name); - if (import_fs22.default.existsSync(rudiPath)) { + const inspected = inspectRuntimeInstall(`runtime:${name}`); + if (inspected.installed) { result.installed = true; - result.path = rudiPath; - result.version = getVersion3(rudiPath); + result.source = "rudi"; + result.path = inspected.primaryBinary.path; + result.version = getVersion3(inspected.primaryBinary.path); } else { - const globalPath = findGlobalBinary2(name); - if (globalPath) { + const legacyRudiPath = import_path20.default.join(PATHS.runtimes, name, "bin", name); + if (!inspected.manifestPresent && import_fs22.default.existsSync(legacyRudiPath)) { result.installed = true; - result.path = globalPath; - result.version = getVersion3(globalPath); + result.source = "rudi"; + result.path = legacyRudiPath; + result.version = getVersion3(legacyRudiPath); + } else if (!inspected.rootExists) { + const globalPath = findGlobalBinary2(name); + if (globalPath) { + result.installed = true; + result.source = "global"; + result.path = globalPath; + result.version = getVersion3(globalPath); + } + } else if (inspected.error) { + result.error = inspected.error; } } result.ready = result.installed; @@ -35674,6 +36310,13 @@ var import_fs24 = __toESM(require("fs"), 1); var import_path22 = __toESM(require("path"), 1); init_src(); init_src5(); +function resolvesToSameFile(leftPath, rightPath) { + try { + return import_fs24.default.realpathSync(leftPath) === import_fs24.default.realpathSync(rightPath); + } catch { + return false; + } +} async function cmdInfo(args, flags) { const pkgId = args[0]; if (!pkgId) { @@ -35698,6 +36341,14 @@ async function cmdInfo(args, flags) { console.warn("Warning: Could not parse manifest.json"); } } + let runtimeInspection = null; + if (kind === "runtime") { + runtimeInspection = inspectRuntimeInstall(pkgId); + if (runtimeInspection.error) { + throw new Error(runtimeInspection.error); + } + manifest = runtimeInspection.manifest; + } console.log(` Package: ${pkgId}`); console.log("\u2500".repeat(50)); @@ -35733,24 +36384,32 @@ Package: ${pkgId}`); if (manifest?.installedAt) { console.log(` Installed: ${new Date(manifest.installedAt).toLocaleString()}`); } - const bins = manifest?.bins || manifest?.binaries || []; + const bins = runtimeInspection ? runtimeInspection.binaries.map((binary) => binary.name) : manifest?.bins || manifest?.binaries || []; if (bins.length > 0) { console.log(` Binaries (${bins.length}):`); console.log("\u2500".repeat(50)); for (const bin of bins) { + const installedRuntimeBinary = runtimeInspection?.binaries.find((binary) => binary.name === bin); const shimPath = import_path22.default.join(PATHS.bins, bin); const validation = validateShim(bin); const ownership = getShimOwner(bin); let shimStatus = "\u2717 no shim"; if (import_fs24.default.existsSync(shimPath)) { if (validation.valid) { - shimStatus = `\u2713 ${validation.target}`; + if (installedRuntimeBinary && !resolvesToSameFile(validation.target, installedRuntimeBinary.resolvedPath)) { + shimStatus = `\u21AA preserved for ${ownership?.owner || "another package"}: ${validation.target}`; + } else { + shimStatus = `\u2713 ${validation.target}`; + } } else { shimStatus = `\u26A0 broken: ${validation.error}`; } } console.log(` ${bin}:`); + if (installedRuntimeBinary) { + console.log(` Installed: \u2713 ${installedRuntimeBinary.path}`); + } console.log(` Shim: ${shimStatus}`); if (ownership) { const ownerMatch = ownership.owner === pkgId; @@ -36020,7 +36679,7 @@ var import_node_http = __toESM(require("node:http"), 1); var import_node_url2 = require("node:url"); // src/daemon/http/context.js -var import_node_crypto5 = __toESM(require("node:crypto"), 1); +var import_node_crypto6 = __toESM(require("node:crypto"), 1); var import_node_url = require("node:url"); // src/daemon/http/errors.js @@ -36092,7 +36751,7 @@ function createDaemonHttpContext() { } catch { } return { - requestId: import_node_crypto5.default.randomUUID(), + requestId: import_node_crypto6.default.randomUUID(), method: req?.method || null, path: pathname, startedAt: Date.now(), @@ -36183,7 +36842,7 @@ function createDaemonHttpContext() { function readBody(req, options = {}) { const maxBodySize = Number.isFinite(options.maxBodySize) && options.maxBodySize > 0 ? options.maxBodySize : DEFAULT_MAX_BODY_BYTES; const timeoutMs = Number.isFinite(options.timeoutMs) && options.timeoutMs > 0 ? options.timeoutMs : DEFAULT_BODY_TIMEOUT_MS; - return new Promise((resolve3, reject) => { + return new Promise((resolve4, reject) => { const chunks = []; let size = 0; let settled = false; @@ -36219,9 +36878,9 @@ function createDaemonHttpContext() { req.on("end", () => { if (settled) return; const raw = Buffer.concat(chunks).toString("utf8"); - if (!raw) return finish(resolve3, {}); + if (!raw) return finish(resolve4, {}); try { - finish(resolve3, JSON.parse(raw)); + finish(resolve4, JSON.parse(raw)); } catch { const failure = new Error("Invalid JSON in request body"); failure.statusCode = 400; @@ -36240,7 +36899,7 @@ function createDaemonHttpContext() { if (!token || typeof candidate !== "string") return false; const expected = Buffer.from(token); const actual = Buffer.from(candidate); - return expected.length === actual.length && import_node_crypto5.default.timingSafeEqual(expected, actual); + return expected.length === actual.length && import_node_crypto6.default.timingSafeEqual(expected, actual); } return { REQUEST_ID_HEADER, @@ -36250,7 +36909,7 @@ function createDaemonHttpContext() { checkAuth: checkAuth2, createRequestContext, error, - generateToken: () => import_node_crypto5.default.randomBytes(32).toString("hex"), + generateToken: () => import_node_crypto6.default.randomBytes(32).toString("hex"), getRequestContext, invalidField, json, @@ -36808,7 +37467,7 @@ function buildLocalLlmRoutes(ctx, deps = {}) { } // src/daemon/routes/agent-host-validation.js -var import_node_path17 = __toESM(require("node:path"), 1); +var import_node_path18 = __toESM(require("node:path"), 1); var MAX_AGENT_HOST_BODY_BYTES = 12 * 1024 * 1024; var LAUNCH_FIELDS = /* @__PURE__ */ new Set([ "approvalMode", @@ -36909,7 +37568,7 @@ function validateRequest(body, allowed, { resume = false } = {}) { } if (!resume) { Object.assign(options, { - originDirectory: import_node_path17.default.resolve(requireText(body.originDirectory, "originDirectory")), + originDirectory: import_node_path18.default.resolve(requireText(body.originDirectory, "originDirectory")), outputDirectory: body.outputDirectory == null ? void 0 : requireText(body.outputDirectory, "outputDirectory"), provider: requireText(body.provider, "provider", 64), workspace: body.workspace == null ? void 0 : requireText(body.workspace, "workspace"), @@ -36979,7 +37638,7 @@ function validateAgentGroupRequest(body) { }); return { groupId: assertAgentGroupId(body.groupId), - originDirectory: import_node_path17.default.resolve(requireText(body.originDirectory, "originDirectory")), + originDirectory: import_node_path18.default.resolve(requireText(body.originDirectory, "originDirectory")), tasks, workspace: requireText(body.workspace, "workspace"), workspaceMode: body.workspaceMode == null ? "auto" : requireText(body.workspaceMode, "workspaceMode", 32) @@ -37207,7 +37866,7 @@ function buildAgentHostRoutes(ctx, dependencies = {}) { // src/daemon/routes/packages.js var import_crypto2 = __toESM(require("crypto"), 1); -var fs49 = __toESM(require("fs/promises"), 1); +var fs51 = __toESM(require("fs/promises"), 1); var fsSync2 = __toESM(require("fs"), 1); var import_path24 = __toESM(require("path"), 1); init_src5(); @@ -37279,7 +37938,7 @@ var defaultDeps = { async function loadManifest3(installPath) { const manifestPath = import_path24.default.join(installPath, "manifest.json"); try { - const content = await fs49.readFile(manifestPath, "utf-8"); + const content = await fs51.readFile(manifestPath, "utf-8"); return JSON.parse(content); } catch { return null; @@ -37424,7 +38083,7 @@ async function checkSecrets3(manifest, deps) { async function parseEnvExample2(installPath) { const examplePath = import_path24.default.join(installPath, ".env.example"); try { - const content = await fs49.readFile(examplePath, "utf-8"); + const content = await fs51.readFile(examplePath, "utf-8"); const keys = []; for (const line of content.split("\n")) { const trimmed = line.trim(); @@ -37440,7 +38099,7 @@ async function parseEnvExample2(installPath) { async function cleanupFailedStackInstall2(stackId, stackPath, removeConfig, deps) { if (stackPath) { try { - await fs49.rm(stackPath, { recursive: true, force: true }); + await fs51.rm(stackPath, { recursive: true, force: true }); } catch { } } @@ -37893,10 +38552,10 @@ function printStartupBanner({ var DEFAULT_SHUTDOWN_TIMEOUT_MS = 5e3; async function closeHttpServer(server) { if (!server || typeof server.close !== "function") return; - await new Promise((resolve3, reject) => { + await new Promise((resolve4, reject) => { server.close((err) => { if (!err || err.code === "ERR_SERVER_NOT_RUNNING") { - resolve3(); + resolve4(); return; } reject(err); @@ -38916,7 +39575,7 @@ async function cmdLeverage(args, flags) { } // src/index.js -var VERSION = true ? "1.10.22" : process.env.npm_package_version || "0.0.0"; +var VERSION = true ? "1.10.25" : process.env.npm_package_version || "0.0.0"; var RETIRED_COMMANDS = /* @__PURE__ */ new Map([ ["apply", "Provider transcripts remain authoritative; organization-plan execution was removed."], ["database", "Use Studio only if you still need the isolated compatibility database."], diff --git a/docs/swe-compliance/2026-08-27-github-auth-readiness.md b/docs/swe-compliance/2026-08-27-github-auth-readiness.md new file mode 100644 index 0000000..9824fde --- /dev/null +++ b/docs/swe-compliance/2026-08-27-github-auth-readiness.md @@ -0,0 +1,125 @@ +## Phase 0: Baseline And Manual Lookup + +- Scope: make `rudi which github` recognize manifest-declared secrets stored in RUDI's canonical secret store. +- Current state: `which` scans account token files and `.env` only, so it falsely reports GitHub auth as not configured. +- Relevant SWE manual sections: Security F3/F6, Testing Doctrine, Agent Co-Pilot Standard, Horizontal Engineering Standard. +- Horizontal scan: `@learnrudi/secrets` already owns secret lookup. Its existing + `hasSecret` path was found to create an absent store, so the shared presence + probe must become non-mutating before `which` can reuse it safely. +- Initial risk tier: High because the command reports credential readiness, though the edit is narrow and read-only. +- Exit criteria: current behavior reproduced in a focused test and isolated worktree. + +## Phase 1: Scope Lock + +- In scope: pass installed manifest metadata into `checkAuth`, check declared + secret names through a non-mutating `@learnrudi/secrets` presence probe, + retain file/account auth discovery, rebuild and validate stack source during + `rudi update`, refresh the installed stack/secret contract before indexing, + test value redaction and absent-store behavior, bump CLI version, and + regenerate the tracked bundle. +- Non-goals: provider API verification in generic CLI lifecycle, secret migration, token mutation, merge. +- Expected files touched: `packages/secrets/src/{index.js,__tests__/unit/secrets.test.js}`, + `packages/core/src/{rudi-config.js,__tests__/unit/rudi-config.test.js}`, + `src/commands/{install.js,update.js,which.js}`, their focused unit tests, + `package.json`, `dist/index.cjs`, and this checklist. The shared-secret files + were added after independent review proved that the existing provider mutated + an absent store and failed on a read-only home. The update/install files were + added after final distribution review proved that normal stack upgrades could + retain stale compiled code and secret requirements. The dedicated + `update-stack-snapshot.test.js` boundary was added after post-fix review found + that a failed in-place stack upgrade could otherwise destroy the accepted + installation. +- Failure behavior: missing optional/required secrets remain unconfigured; secret values never appear in output. +- Authorized actions: user authorized implementation, commits, feature-branch publication, and local CLI activation; merge is not authorized. +- Commit strategy: source/test/version slice after green; generated bundle slice after full build. +- Horizontal disposition: consolidate on existing `@learnrudi/secrets` contract. +- Exit criteria: interface and rollback boundary recorded. + +## Phase 2: Red Tests + +- Observable behavior: an installed stack declaring `GITHUB_TOKEN` reports + configured when the presence probe succeeds, without returning that value; + checking an absent store does not create it; unrelated `.env` values and + incomplete optional multi-secret contracts do not produce false readiness; + padded secret names fail closed; stack updates force a rebuild, validate the + result, refresh persisted metadata, and do not index failed builds; quoted + empty `.env` values do not count as credentials. +- Test files: `src/__tests__/unit/{stack-runtime-detection.test.js,update-command.test.js,install-stack-build.test.js}`, + `packages/secrets/src/__tests__/unit/secrets.test.js`, and + `packages/core/src/__tests__/unit/rudi-config.test.js`. +- Red commands: `pnpm test -- src/__tests__/unit/stack-runtime-detection.test.js` + failed because `checkAuth` ignored the manifest; after independent review, + `pnpm test -- packages/secrets/src/__tests__/unit/secrets.test.js` failed with + `after: true` for an initially absent store. Subsequent review cases failed + on padded names, partial optional credentials, unrelated `.env` values, stale + compiled entries, and stale persisted required-secret metadata. + Final review cases then failed because `runUpdate` mutated the installed + stack before build/validation succeeded and did not restore its lockfile, and + because the installer's external state migration survived a failed rollback. + The last compensation review proved that copy-then-delete and reverse rename + still exposed exact recovery data to concurrent deletion or overwrite. Three + preservation tests were added with the fix; a separate red execution was + skipped because the prior `unlink`, recursive removal, and overwrite-capable + rename lines were the direct read-only failure evidence. +- Exit criteria: deterministic behavioral red. + +## Phase 3: Implementation + +- Reuse `@learnrudi/secrets`; make its presence-only probe non-mutating; require + runtime-valid manifest names; dependency-inject lookup for tests; do not read + secret values directly from `which`. +- Preserve existing account-token and declared `.env` compatibility. For an + all-optional multi-secret contract with no explicit alternative-group schema, + report readiness conservatively only when every declared field is present. +- Force stack builds on update even when an old entry point exists, validate + before indexing, and re-register canonical runtime/command/secret/version + metadata from the installed manifest. +- Snapshot the exact managed stack directory and lockfile before mutation; + include the exact external stack-state root in that transaction; reject + symlinked managed-path ancestors; preflight all components; verify external + state generation before rollback; compensate in reverse order if promotion + fails; and discard the owner-only backup only after the accepted update + succeeds. +- On rollback-compensation failure, copy without overwrite, retain every exact + staged source, preserve promoted accepted data in the private recovery area, + and report both live and recovery paths rather than claiming atomic success. +- Exit criteria: red command passes unchanged. + +## Phase 4: Green Tests And Refactor + +- Green command: the exact focused targets passed together (57 tests), then a + full `pnpm test` passed 690 tests across 43 suites. +- Refactor constraints: no unrelated CLI auth/lifecycle changes; install helpers + were exported only to keep install and update build/validation semantics shared. +- Commit checkpoint: source/test/version first; bundle after reproducible build. +- Exit criteria: focused/full green and no secret output. + +## Phase 5: Full Verification + +- Completed: `pnpm test` (690/690), focused tests (57/57), `pnpm build`, + reproducible tracked bundle, edited-file debt scan (261 graph files, 9 edited + files reported, 0 findings), `git diff --check`, and + `npm pack --dry-run` (`@learnrudi/cli@1.10.23`, 6 files). +- Live smoke: install CLI on Admin Mac and verify `rudi which github` reports configured while printing no value. +- Independent review: review findings covered the mutating `hasSecret` path, + whitespace/malformed secret boundaries, optional multi-secret optimism, + unrelated `.env` fallback, stale compiled stack code, and stale persisted + secret requirements. Post-fix reviews additionally found non-transactional + update failure and an external migrated-state rollback leak; those findings + were reproduced red and resolved with exact stack, lockfile, and state-root + restoration. The final scoped review then found symlink-ancestor escape, + partial rollback, concurrent state-rewind, cleanup classification, and inline + `.env` comment edges; those were reproduced red and resolved with canonical + containment, full preflight/compensation, a state-generation guard, separate + cleanup reporting, and decoded value checks. Each finding was resolved at its + owning boundary. The final compensation review found three destructive race + windows and one recovery-path observability gap; preservation-only copying, + retained exact sources, explicit recovery paths, and three focused tests + closed them. The last independent read-only review returned no findings. +- Exit criteria: no blocking findings. + +## Phase 6: Docs, Contracts, And Closure + +- Record commands/results, commit ledger, PR URL, Admin activation, primary-Mac update/readback, accepted debt, and proof gaps here. +- Worktree closeout: create a non-mutating closeout receipt before cleanup eligibility is considered. +- Definition of Done: source, package, installed CLI, and paired-Mac readback agree. diff --git a/docs/swe-compliance/2026-08-29-cli-rollout-reconciliation.md b/docs/swe-compliance/2026-08-29-cli-rollout-reconciliation.md new file mode 100644 index 0000000..457d699 --- /dev/null +++ b/docs/swe-compliance/2026-08-29-cli-rollout-reconciliation.md @@ -0,0 +1,75 @@ +# CLI Rollout Reconciliation — SWE Compliance Record + +## Phase 0: Baseline And Manual Lookup + +- Scope: reconcile merged suite-aware CLI main `664265cdcc0a1d407d31ee1648956d717bfd7c04` with readiness head `5b884e239886d0aa833d583ae1c60f11d6d714a8` in a fresh isolated worktree without committing or publishing. +- Worktree: `/Users/admin/RUDI/worktrees/cli/cli-rollout-reconcile-20260829`. +- Branch: `codex/cli-rollout-reconcile-20260829`, created from live `origin/main`. +- Merge base: `16f4c1fe12d96cc339dadd258ff6dae799e4144d`. +- Relevant manual sections: Engineering Quick Reference, Agent Co-Pilot Operating Standard, Horizontal Engineering And Codebase Stewardship Standard, and RUDI Agentic Engineering Standard. +- Horizontal scan: the two branches intentionally modify the same `install` and `update` ownership boundary. Reconciliation preserves one shared update flow; it does not add a third implementation or a new horizontal obligation. +- Initial risk tier: medium, because package update/rollback and native projection behavior are user-visible and affect local package state in a later rollout. +- Exit criteria: both lineages remain represented by `HEAD` and `MERGE_HEAD`, conflicts are resolved, version is greater than `1.10.23`, the generated bundle is current, and prescribed verification passes. + +## Phase 1: Scope Lock + +- In scope: no-commit merge reconciliation, conflict resolution, version `1.10.24`, tracked bundle regeneration, tests, build, debt scan, and dry-run package proof. +- Non-goals: commit, push, PR, merge, tag, npm publication, CLI installation, skill synchronization, worktree cleanup, and sports/NFL changes. +- Invariants: + - suite-aware `related.skills` planning and targeted native projection remain fail-closed; + - stack updates retain transactional snapshot, validation, registration, and rollback behavior; + - bare whole-inventory updates and force projections still require explicit scope; + - dry-run performs no package, stack-index, or native-wrapper mutation; + - package version increases above every currently installed/accepted CLI version. +- Failure behavior: any update, build, validation, rollback, projection, or package verification failure blocks the later commit gate. +- Authorized external actions: read-only fetch and local verification only. +- Commit strategy: one later merge commit preserving first parent `664265cdcc0a1d407d31ee1648956d717bfd7c04` and second parent `5b884e239886d0aa833d583ae1c60f11d6d714a8`; committing is not authorized in this gate. +- Horizontal disposition: no action; one update command continues to own both suite selection and transactional stack replacement. + +## Phase 2: Red Tests + +- The initial combined focused run retained both lineages' tests and exposed three semantic failures: explicit target-stack failures were being collected like `--all` or related-skill failures instead of rejecting after rollback, and suite test fixtures lacked the installed stack path required by the transactional snapshot contract. +- Added one behavior-level reconciliation test: `runUpdate aborts related suite work when the target stack update rolls back`. +- Red command: `pnpm test -- --test-concurrency=1 --test-name-pattern='aborts related suite work' src/__tests__/unit/update-command.test.js`. +- Expected red result: `Missing expected rejection`, proving that the merged flow continued into related work after the requested target failed. +- Existing tests from both accepted lineages remain the wider characterization and regression proof. + +## Phase 3: Implementation + +- Preserve the readiness branch's transactional stack snapshot/build/validation/rollback implementation. +- Preserve main's exact package targeting, suite expansion through Registry `related.skills`, targeted host projection, truthful dry-run/JSON output, and whole-inventory safeguards. +- Reconcile the failure boundary so a failed explicitly requested target is restored and rethrown before related work, while a later related-skill failure remains a structured partial failure and `--all` retains inventory-wide result collection. +- Supply the real installed-stack `path` contract in suite update test fixtures; production inventory already supplies this field. +- Resolve generated `dist/index.cjs` only by running `pnpm build`. +- Do not add dependencies or broaden the command surface. + +## Phase 4: Green Tests And Refactor + +- Green command for the added behavior test: `pnpm test -- --test-concurrency=1 --test-name-pattern='aborts related suite work' src/__tests__/unit/update-command.test.js`; result: 1 passed, 20 skipped. +- Focused reconciliation command: `pnpm test -- --test-concurrency=1 src/__tests__/unit/update-command.test.js src/__tests__/unit/update-stack-snapshot.test.js src/__tests__/unit/install-stack-build.test.js src/__tests__/unit/skills-sync.test.js src/__tests__/unit/install-related-skills.test.js src/__tests__/unit/command-surface-contract.test.js src/__tests__/unit/stack-runtime-detection.test.js packages/utils/src/__tests__/unit/args.test.js packages/core/src/__tests__/unit/rudi-config.test.js packages/secrets/src/__tests__/unit/secrets.test.js`; result: 134 passed, 0 failed. +- No behavior was weakened and no dependency was added. The fresh worktree reused the existing canonical checkout's dependency store through ignored local links solely for verification; no package installation occurred. +- Leave all reconciliation changes uncommitted pending a separate commit gate. + +## Phase 5: Full Verification + +- `pnpm test`: 715 passed, 0 failed across 566 top-level tests and 43 suites. +- `pnpm build`: passed and regenerated the tracked bundle with `rudi v1.10.24`. +- Generated-bundle proof: a second `pnpm build` produced the same SHA-256 values: `dist/index.cjs` `a78d7adab78cdf4c76fdf494ff242e49d0b4e8a634078a88e66bab5a5b963352`, `dist/router-mcp.js` `3c5f0d94fb4d44a8220c0331ba3b68f2918a56dfbebf0122fbdcdbdc2a6881f6`, and `dist/packages-manifest.json` `607aaf582c29aa92627e51823525fe43f38fa1db54a2874db457122771dbadc6`. +- `node scripts/agent-debt-runner.mjs --changed-since origin/main --no-log`: passed with 0 findings. +- Packaged `stack:swe-engineering` debt scan at warning severity: passed with 0 errors, warnings, or informational findings. +- `npm pack --dry-run`: passed for `@learnrudi/cli@1.10.24`; the six-file payload contains `LICENSE`, `README.md`, `dist/index.cjs`, `dist/packages-manifest.json`, `dist/router-mcp.js`, and `package.json`. No tarball was written. +- Built-artifact smoke proof: `node dist/index.cjs --version` reported `rudi v1.10.24`; `node dist/index.cjs update --help` documented exact targeting, suite expansion, projection, dry-run/JSON behavior, and the `--all` safeguard. +- Fail-closed smoke proof: bare `node dist/index.cjs update --json` under a nonexistent isolated `RUDI_HOME` exited 1 with exactly one structured error requiring a package ID or `--all`, and did not create the isolated root. +- Whole-inventory projection safeguard: `node dist/index.cjs skills sync codex --force --dry-run --json` under a second nonexistent isolated `RUDI_HOME` exited 1 with exactly one structured refusal requiring explicit `--all`, and did not create the isolated root. +- Targeted skill projection and suite-aware dry-run mutation boundaries are covered by the 134-test focused command. A live installed-inventory dry-run was intentionally not used because Registry metadata may refresh, which is outside this source-only gate. +- Independent review: a fresh-context independent agent is not authorized by this gate; perform bounded local diff review and record this as the remaining review proof gap for the commit gate. + +## Phase 6: Docs, Contracts, And Closure + +- Changed source groups: package version; stack update transaction and suite failure boundary; stack config/secret-readiness handling; install/which runtime readiness behavior; focused tests; inherited readiness compliance evidence; generated CLI bundle; this reconciliation record. +- Accepted debt: none. Both debt gates report zero findings. +- Review result: bounded self-review and Git hygiene checks found no unresolved entries, unstaged source changes, conflict markers, whitespace errors, unexpected paths, or lineage drift. +- Proof gap: independent fresh-context review remains for the later commit gate because it was not authorized in this gate. +- Publication status: local uncommitted merge only. +- Worktree closeout receipt: deferred until the reconciliation is accepted and the receipt-writing gate is separately authorized. +- Final verdict: verification complete; ready for a separately approved local merge-commit gate if final staged-diff and lineage checks remain clean. diff --git a/docs/swe-compliance/2026-08-31-versioned-runtime-inspection.md b/docs/swe-compliance/2026-08-31-versioned-runtime-inspection.md new file mode 100644 index 0000000..e03ce03 --- /dev/null +++ b/docs/swe-compliance/2026-08-31-versioned-runtime-inspection.md @@ -0,0 +1,199 @@ +# Versioned Runtime Inspection Repair + +Status: implementation active + +## Phase 0: Baseline And Manual Lookup + +- Scope: make `rudi check` and `rudi info` truthfully inspect an installed + versioned runtime whose package ID differs from its executable name, without + changing installation layout or generic shims. +- Triggering live evidence: `runtime:node-20-20-2` installed successfully at + `/Users/admin/.rudi/runtimes/node-20-20-2`; `rudi list runtimes` sees it, but + `rudi check runtime:node-20-20-2 --json` reports `installed:false` because it + probes `bin/node-20-20-2`. `rudi info` reports shared Node 20.10.0 shim targets + under the versioned package without distinguishing that they belong to the + preserved shared runtime. +- Files inspected: global/CLI `AGENTS.md`, `src/commands/check.js`, + `src/commands/info.js`, environment package-path mapping, installer manifests, + shim validation/ownership helpers, existing command tests, and live installed + runtime/shim state. +- Relevant SWE standards: Engineering Quick Reference, Agent Co-Pilot Operating + Standard, Infrastructure And Deployment Engineering Standard, Testing + Doctrine, and Horizontal Engineering And Codebase Stewardship Standard. +- Baseline: clean isolated worktree + `/Users/admin/RUDI/worktrees/cli/versioned-runtime-inspection-20260831` on + `fix/versioned-runtime-inspection-20260831`, based on `origin/main` + `6c6bb1dbda35bc257120113119aad6c7b79575cf`. +- Release-lineage constraint: the installed CLI is version `1.10.23` with + bundle SHA-256 + `8d6a3f55f92712490f38abf3ea995d8f2f93b91414bc2857a794faa1b1b87f2b`, + matching the preserved GitHub-readiness worktree rather than `origin/main`. + The already-verified reconciliation commit + `9629087d278f9265006c5625240de5bfac67e355` combines that readiness lineage + with suite-aware main at version `1.10.24`; the release branch must preserve + it before publishing this fix and must use a version greater than `1.10.24`. +- Horizontal scan: package-path derivation already supports arbitrary runtime + IDs. The defect is duplicated runtime-binary inference in command adapters: + `check` assumes executable name equals package name, while `info` treats any + same-name global shim as package-owned. Disposition: standardize inspection + around the installed manifest/root; do not change installer or shim ownership. +- Risks/invariants: explicit runtime IDs never fall back to an unrelated global + executable; malformed/missing manifests fail closed; ordinary `runtime:node`, + `runtime:python`, and `runtime:node24` remain compatible; generic shims remain + untouched; command output never implies a foreign shim belongs to the package. +- Risk tier: Medium. This changes operational readiness reporting but not + package installation, runtime bytes, shims, credentials, or services. +- Exit criteria: deterministic reproduction, exact scope, tests, rollback, and + publication/install path recorded. + +## Phase 1: Scope Lock + +- In scope: add focused command-level tests; select installed runtime binaries + from the exact root's manifest; distinguish installed binaries from shared or + foreign shim targets in `rudi info`; build/publish/install the verified CLI; + read back the live versioned runtime and shared shim invariants. +- Non-goals: change Registry/installer layout, create versioned shims, replace + generic Node shims, alter runtime contents, edit Compute, rebuild applications, + change credentials, remove old CLI/runtime files, or clean unrelated worktrees. +- Expected files: `src/commands/check.js`, `src/commands/info.js`, shared + `src/runtime-inspection.js`, one focused test file, package-version metadata, + generated `dist/index.cjs`, and this compliance record. The verified + reconciliation commit is included as an explicit merge parent rather than + copied or reimplemented. +- Trust boundaries: installed manifest contents, filesystem type/permissions, + executable invocation, shim target/ownership metadata, GitHub checks, package + artifact, and post-install readback. +- Failure behavior: absent/malformed manifest binaries, paths outside the exact + install root, non-files/non-executables, version probe failure, foreign shims, + failing tests/build/debt/review/CI, or live mismatch stop the E2E chain. +- Authorized actions: the user's 2026-08-31 lead-engineer E2E clearance covers + this necessary source/commit/PR/merge/build/install remediation. Direct-main + pushes, shim replacement, destructive cleanup, and retained-state overwrite + remain excluded. +- Commit strategy: one green source/test/compliance slice; a dedicated generated + build slice only if repository convention requires tracked `dist`; stage and + inspect task-owned paths only. +- Review gates: two red-green slices, targeted/full tests, build reproducibility, + focused and repository debt scans, fresh independent review, GitHub CI/merge, + package/install provenance, and live readback. +- Exit criteria: no unresolved scope or ownership ambiguity. + +## Phase 2: Red Tests + +- Behavior 1: `rudi check runtime:node-20-20-2 --json` reads the exact installed + manifest/root and reports `bin/node`, version 20.20.2, installed and ready. +- Behavior 2: `rudi info runtime:node-20-20-2` reports its exact installed + binaries and labels generic Node shims as preserved for another package. +- Test file: `src/__tests__/unit/versioned-runtime-inspection.test.js`. +- Red commands: run each named test with `node --test --test-name-pattern`. +- Expected failures: Behavior 1 reports absent; Behavior 2 presents the shared + target as the versioned package's valid shim and omits the installed path. +- Results: Behavior 1 failed with exit status 1 and `installed:false` because + the command probed `bin/node-20-20-2`. Behavior 2 failed because the output + omitted the exact installed path and printed the preserved shared Node shim + as a valid shim for the versioned package. Both failures matched the locked + expectations. +- Exit criteria: each red fails for its exact behavior after test setup passes. + +## Phase 3: Implementation + +- Rules: derive the runtime root with `getPackagePath`; parse only its manifest; + accept declared bin names/paths only when they resolve inside the runtime root; + preserve legacy fallback for unmanifested runtimes; never create/change shims. +- Files allowed: only Phase 1 paths. +- Validation: regular/executable runtime binary, inside-root containment, bounded + manifest shapes, deterministic preferred executable, truthful foreign-shim + labeling, and visible version-probe failure. +- Observability: JSON check returns exact path/version/ready; info prints exact + installed binary and separately describes shim disposition. +- Exit criteria: both unchanged red commands pass. + +## Phase 4: Green Tests And Refactor + +- Green commands: the exact Phase 2 commands. +- Refactor: share only the minimum manifest/bin normalization required to prevent + the two command adapters from drifting; do not widen into installer changes. +- Regression: existing agent/check, shim, binary/runtime, package info, and + environment tests; `git diff --check`. +- Results: both unchanged red commands pass. Two additional boundary tests also + pass: a manifest binary that escapes the package root fails closed, and the + ordinary shared `runtime:node` continues to report its exact owned shim. +- Independent review found that a missing or empty manifest ID was still + accepted because mismatch validation was conditional on a truthy ID. Two + unchanged regression cases failed red with exit 0 and `installed:true`, then + passed green after the inspector required exact `manifest.id === packageId`. +- Focused/adjacent command: `node --test --test-concurrency=1` over the new test, + binary/runtime, legacy-runtime, shim, external-agent, and environment suites; + result: 88 passed, 0 failed. `git diff --check` passed. +- Release-lineage reconciliation merged verified commit + `9629087d278f9265006c5625240de5bfac67e355` into the current-main branch. + The newer GitHub-tree installer overlapped in `buildStackIfNeeded` and update + tests. Resolution preserved both contracts: explicit authorization for + downloaded build scripts plus the reconciliation's forced-build, injectable + npm/runner, transaction, rollback, and suite behavior. The generated bundle + was regenerated rather than hand-resolved. +- Post-resolution union command: `pnpm test -- --test-concurrency=1` over the + runtime inspection, update/snapshot/build/suite, GitHub resolver/installer, + secrets, runtime-detection, command-surface, and argument suites; result: + 146 passed, 0 failed. +- Exit criteria: focused and adjacent tests pass without assertion weakening. + +## Phase 5: Full Verification + +- Targeted tests: focused runtime inspection plus adjacent command/runtime suites. +- Full suite: `pnpm test`. +- Build: `pnpm build`; verify tracked bundle reproduction and package contents. +- Debt: changed-file runner and RUDI focused debt scan. +- Live smoke: install the exact reviewed CLI artifact, run check/info against the + versioned runtime, and rehash both versioned/shared binaries and generic shims. +- Independent review: fresh read-only diff and evidence review before publication. +- Results: + - `pnpm test`: 748 passed, 0 failed across 599 top-level tests and 43 + suites. + - Two consecutive `pnpm build` runs reproduced `dist/index.cjs` SHA-256 + `1248e743a7e7f24eb5aebc2a87df49f98ca5c2fb94dce46f0dfe1c9fd5dee976`, + `dist/router-mcp.js` + `3c5f0d94fb4d44a8220c0331ba3b68f2918a56dfbebf0122fbdcdbdc2a6881f6`, + and `dist/packages-manifest.json` + `607aaf582c29aa92627e51823525fe43f38fa1db54a2874db457122771dbadc6`. + Built smoke reports `rudi v1.10.25`. + - Repository changed-file debt runner: 0 findings. Packaged SWE debt scan + with the repository's `pr-review` profile: 0 errors, warnings, or + informational findings. A first packaged scan without that profile produced + 12 false orphan warnings because it omitted the repository entrypoint + configuration; it was superseded by the canonical profile result. + - `npm pack --dry-run`: six-file `@learnrudi/cli@1.10.25` payload; no tarball + was written. + - Pre-install built-artifact smoke binds `runtime:node-20-20-2` to version + `20.20.2` at `/Users/admin/.rudi/runtimes/node-20-20-2/bin/node` and the + shared runtime to version `20.10.0` at + `/Users/admin/.rudi/runtimes/node/bin/node`. Their SHA-256 values remain + `afea68f4c6280aa32707b2c037084931114a72b5c65412371244e060390c1fc6` + and `f77cb37948c962b3d171f48db7589335ee244299eaf8e267f9840e047cf1ff40`; + the generic Node shim still targets the shared runtime. + - Initial independent review: P0 0, P1 1, P2 0. The sole P1 was the missing + or empty manifest-ID acceptance above. It was reproduced and resolved; + final-delta independent review returned PASS with P0 0, P1 0, P2 0 and no + remaining findings. The reviewer matched the exact corrected source, test, + bundle, and compliance blobs and confirmed the bundle's strict identity + check, version, and recorded SHA-256. + - `pnpm audit`: unchanged dependency graph reports eight existing advisories + (three moderate, five high) in `ajv`/`fast-uri`, `yaml`, and `uuid`. No + dependency or lockfile changed in this release. Treat as disclosed existing + dependency debt for a separate dependency-upgrade gate, not as a reason to + broaden this runtime-identity repair. +- Exit criteria: no blocking finding or provenance gap. + +## Phase 6: Docs, Contracts, And Closure + +- Docs/contracts: this compliance record and truthful command output contracts. +- Final paths/results/review/commits/publication: pending. +- Horizontal obligation: close the command-adapter inference drift with exact + tests; no installer/shim consolidation obligation expected. +- Verdict: active. +- Accepted debt: eight pre-existing workspace dependency advisories disclosed + above; no dependency or lockfile delta is present in this release. +- Proof gaps: implementation through live readback and closeout are pending. +- Definition of Done: merged reviewed source, verified artifact installed through + an accepted release mechanism, live `check`/`info` truthfully bind the exact + versioned runtime, shared shims/runtime remain unchanged, and E2E resumes. diff --git a/package.json b/package.json index 2515583..e915606 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@learnrudi/cli", - "version": "1.10.22", + "version": "1.10.25", "packageManager": "pnpm@10.22.0", "description": "RUDI CLI - Install and manage local MCP stacks, runtimes, daemon lifecycle, and agent router integrations", "type": "module", diff --git a/packages/core/src/__tests__/unit/rudi-config.test.js b/packages/core/src/__tests__/unit/rudi-config.test.js index 2a991c8..c091450 100644 --- a/packages/core/src/__tests__/unit/rudi-config.test.js +++ b/packages/core/src/__tests__/unit/rudi-config.test.js @@ -66,3 +66,50 @@ test('stack config normalizes secret key definitions and preserves shared metada fs.rmSync(root, { recursive: true, force: true }); } }); + +test('stack config refreshes owned secret requirement metadata on upgrade', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'rudi-config-upgrade-')); + const rudiHome = path.join(root, '.rudi'); + + try { + fs.mkdirSync(rudiHome, { recursive: true }); + + const script = ` + const { addStack, readRudiConfig } = await import(process.argv[1]); + + addStack('stack:github', { + path: '/tmp/github', + runtime: 'node', + command: ['node', 'dist/index.js'], + secrets: [{ name: 'GITHUB_TOKEN', required: true }], + version: '1.0.0' + }); + addStack('stack:github', { + path: '/tmp/github', + runtime: 'node', + command: ['node', 'dist/index.js'], + secrets: [{ name: 'GITHUB_TOKEN', required: false }], + version: '1.0.1' + }); + console.log(JSON.stringify(readRudiConfig())); + `; + + const output = execFileSync(process.execPath, ['--input-type=module', '-e', script, rudiConfigUrl], { + cwd: repoRoot, + env: { + ...process.env, + RUDI_HOME: rudiHome, + }, + encoding: 'utf8', + }); + + const config = JSON.parse(output); + assert.deepEqual(config.stacks['stack:github'].secrets, [ + { name: 'GITHUB_TOKEN', required: false }, + ]); + assert.equal(config.stacks['stack:github'].version, '1.0.1'); + assert.equal(config.secrets.GITHUB_TOKEN.required, false); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/packages/core/src/rudi-config.js b/packages/core/src/rudi-config.js index a75f494..4f08e71 100644 --- a/packages/core/src/rudi-config.js +++ b/packages/core/src/rudi-config.js @@ -406,6 +406,8 @@ export function addStack(stackId, stackInfo) { stack: stackId, required: secret.required }; + } else if (config.secrets[secret.name].stack === stackId) { + config.secrets[secret.name].required = secret.required; } } }); diff --git a/packages/secrets/src/__tests__/unit/secrets.test.js b/packages/secrets/src/__tests__/unit/secrets.test.js index f222e47..011d731 100644 --- a/packages/secrets/src/__tests__/unit/secrets.test.js +++ b/packages/secrets/src/__tests__/unit/secrets.test.js @@ -194,6 +194,79 @@ test('hasSecret: returns false for null', () => { assert.ok(!has); }); +test('hasSecret: does not create an absent secrets store', () => { + const rudiHome = fs.mkdtempSync(path.join(os.tmpdir(), 'rudi-secrets-')); + + try { + const result = runIsolatedSecretsScript(` + import { existsSync } from 'node:fs'; + import { getStorageInfo, hasSecret } from '@learnrudi/secrets'; + + const file = getStorageInfo().file; + const before = existsSync(file); + const configured = await hasSecret('MISSING_SECRET'); + const after = existsSync(file); + console.log(JSON.stringify({ before, configured, after })); + `, rudiHome); + + assert.deepStrictEqual(result, { + before: false, + configured: false, + after: false, + }); + } finally { + fs.rmSync(rudiHome, { recursive: true, force: true }); + } +}); + +test('hasSecret: treats a whitespace-only stored value as not configured', () => { + const rudiHome = fs.mkdtempSync(path.join(os.tmpdir(), 'rudi-secrets-')); + + try { + fs.writeFileSync( + path.join(rudiHome, 'secrets.json'), + JSON.stringify({ GITHUB_TOKEN: ' ' }), + { mode: 0o600 }, + ); + + const result = runIsolatedSecretsScript(` + import { hasSecret } from '@learnrudi/secrets'; + console.log(JSON.stringify({ configured: await hasSecret('GITHUB_TOKEN') })); + `, rudiHome); + + assert.strictEqual(result.configured, false); + } finally { + fs.rmSync(rudiHome, { recursive: true, force: true }); + } +}); + +test('getMaskedSecrets: preserves whitespace-only values as pending', () => { + const rudiHome = fs.mkdtempSync(path.join(os.tmpdir(), 'rudi-secrets-')); + + try { + fs.writeFileSync( + path.join(rudiHome, 'secrets.json'), + JSON.stringify({ GITHUB_TOKEN: ' ' }), + { mode: 0o600 }, + ); + + const result = runIsolatedSecretsScript(` + import { getMaskedSecrets, hasSecret } from '@learnrudi/secrets'; + console.log(JSON.stringify({ + configured: await hasSecret('GITHUB_TOKEN'), + masked: (await getMaskedSecrets()).GITHUB_TOKEN, + })); + `, rudiHome); + + assert.deepStrictEqual(result, { + configured: false, + masked: '(pending)', + }); + } finally { + fs.rmSync(rudiHome, { recursive: true, force: true }); + } +}); + // ============================================================================= // LIST SECRETS LOGIC // ============================================================================= diff --git a/packages/secrets/src/index.js b/packages/secrets/src/index.js index 557c830..7881c6c 100644 --- a/packages/secrets/src/index.js +++ b/packages/secrets/src/index.js @@ -55,6 +55,17 @@ export function loadSecrets() { } } +function loadSecretsWithoutMutation() { + try { + if (!fs.existsSync(SECRETS_FILE)) return {}; + const content = fs.readFileSync(SECRETS_FILE, 'utf-8'); + const secrets = JSON.parse(content); + return isSecretsObject(secrets) ? secrets : {}; + } catch { + return {}; + } +} + /** * Save secrets to file (atomic write) */ @@ -125,8 +136,9 @@ export async function listSecrets() { * Check if a secret exists */ export async function hasSecret(name) { - const secrets = loadSecrets(); - return secrets[name] !== undefined && secrets[name] !== null && secrets[name] !== ''; + const secrets = loadSecretsWithoutMutation(); + const value = secrets[name]; + return typeof value === 'string' && value.trim() !== ''; } /** @@ -137,12 +149,12 @@ export async function getMaskedSecrets() { const masked = {}; for (const [name, value] of Object.entries(secrets)) { - if (value && typeof value === 'string' && value.length > 8) { + if (typeof value !== 'string' || value.trim() === '') { + masked[name] = '(pending)'; + } else if (value.length > 8) { masked[name] = value.slice(0, 4) + '...' + value.slice(-4); - } else if (value && typeof value === 'string' && value.length > 0) { - masked[name] = '****'; } else { - masked[name] = '(pending)'; + masked[name] = '****'; } } diff --git a/src/__tests__/unit/install-stack-build.test.js b/src/__tests__/unit/install-stack-build.test.js new file mode 100644 index 0000000..b7de266 --- /dev/null +++ b/src/__tests__/unit/install-stack-build.test.js @@ -0,0 +1,40 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { buildStackIfNeeded } from '../../commands/install.js'; + +test('forced stack build runs when a compiled entry point already exists during update', async () => { + const stackPath = await mkdtemp(path.join(os.tmpdir(), 'rudi-stack-build-')); + try { + await mkdir(path.join(stackPath, 'dist')); + await writeFile(path.join(stackPath, 'dist', 'index.js'), 'old build'); + await writeFile(path.join(stackPath, 'package.json'), JSON.stringify({ + scripts: { build: 'build-command' }, + })); + + const calls = []; + const result = await buildStackIfNeeded( + stackPath, + { runtime: 'node', command: ['node', 'dist/index.js'] }, + { + force: true, + npmCommand: 'npm-for-test', + runBuildCommand(command, args, options) { + calls.push({ command, args, cwd: options.cwd }); + }, + }, + ); + + assert.deepEqual(result, { built: true }); + assert.deepEqual(calls, [{ + command: 'npm-for-test', + args: ['run', 'build'], + cwd: stackPath, + }]); + } finally { + await rm(stackPath, { recursive: true, force: true }); + } +}); diff --git a/src/__tests__/unit/stack-runtime-detection.test.js b/src/__tests__/unit/stack-runtime-detection.test.js index 8e7e37f..4aba308 100644 --- a/src/__tests__/unit/stack-runtime-detection.test.js +++ b/src/__tests__/unit/stack-runtime-detection.test.js @@ -1,4 +1,5 @@ import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; @@ -130,6 +131,214 @@ test('which auth status finds account tokens in RUDI stack state', async () => { await rm(home, { recursive: true, force: true }); }); +test('which auth status finds manifest-declared credentials without reading secret values', async () => { + const home = await mkdtemp(path.join(os.tmpdir(), 'rudi-home-')); + await withTempStack( + async () => {}, + async (dir) => { + const calls = []; + const result = await checkAuth(dir, 'node', { + rudiHome: home, + stack: { + id: 'stack:github', + requires: { + secrets: [ + { name: 'GITHUB_TOKEN', required: true }, + { name: 'GITHUB_API_BASE_URL', required: false }, + ], + }, + }, + async hasSecret(name) { + calls.push(name); + return name === 'GITHUB_TOKEN'; + }, + }); + + assert.equal(result.configured, true); + assert.deepEqual(result.files, ['RUDI secrets (GITHUB_TOKEN)']); + assert.deepEqual(calls, ['GITHUB_TOKEN', 'GITHUB_API_BASE_URL']); + assert.equal('getSecret' in result, false); + }, + ); + await rm(home, { recursive: true, force: true }); +}); + +test('which auth status does not create an absent RUDI secrets store', async () => { + const home = await mkdtemp(path.join(os.tmpdir(), 'rudi-home-')); + await withTempStack( + async () => {}, + async (dir) => { + const output = execFileSync(process.execPath, ['--input-type=module', '--eval', ` + import { existsSync } from 'node:fs'; + import { checkAuth } from './src/commands/which.js'; + + const secretsPath = process.env.RUDI_HOME + '/secrets.json'; + const result = await checkAuth(${JSON.stringify(dir)}, 'node', { + rudiHome: process.env.RUDI_HOME, + stack: { + id: 'stack:github', + requires: { secrets: [{ name: 'GITHUB_TOKEN', required: true }] }, + }, + }); + console.log(JSON.stringify({ result, secretsFileExists: existsSync(secretsPath) })); + `], { + cwd: process.cwd(), + env: { ...process.env, RUDI_HOME: home }, + encoding: 'utf-8', + }); + const { result, secretsFileExists } = JSON.parse(output); + + assert.equal(result.configured, false); + assert.equal(secretsFileExists, false); + }, + ); + await rm(home, { recursive: true, force: true }); +}); + +test('which auth status rejects malformed required secret metadata', async () => { + await withTempStack( + async () => {}, + async (dir) => { + await assert.rejects( + () => checkAuth(dir, 'node', { + stack: { + id: 'stack:malformed', + requires: { + secrets: [ + { name: '', required: true }, + { name: 'OPTIONAL_TOKEN', required: false }, + ], + }, + }, + async hasSecret(name) { + return name === 'OPTIONAL_TOKEN'; + }, + }), + /Invalid stack secret name at index 0/, + ); + }, + ); +}); + +test('which auth status rejects secret names the runtime would not inject verbatim', async () => { + await withTempStack( + async () => {}, + async (dir) => { + await assert.rejects( + () => checkAuth(dir, 'node', { + stack: { + id: 'stack:malformed', + requires: { + secrets: [{ name: ' GITHUB_TOKEN ', required: true }], + }, + }, + async hasSecret(name) { + return name === 'GITHUB_TOKEN'; + }, + }), + /Invalid stack secret name at index 0/, + ); + }, + ); +}); + +test('which auth status does not infer complete auth from one optional secret in a multi-secret contract', async () => { + await withTempStack( + async () => {}, + async (dir) => { + const result = await checkAuth(dir, 'node', { + stack: { + id: 'stack:composite', + requires: { + secrets: [ + { name: 'SERVICE_ACCOUNT', required: false }, + { name: 'SERVICE_KEY', required: false }, + ], + }, + }, + async hasSecret(name) { + return name === 'SERVICE_ACCOUNT'; + }, + }); + + assert.equal(result.configured, false); + }, + ); +}); + +test('which auth status accepts a present single optional secret contract', async () => { + await withTempStack( + async () => {}, + async (dir) => { + const result = await checkAuth(dir, 'node', { + stack: { + id: 'stack:github', + requires: { + secrets: [{ name: 'GITHUB_TOKEN', required: false }], + }, + }, + async hasSecret(name) { + return name === 'GITHUB_TOKEN'; + }, + }); + + assert.equal(result.configured, true); + assert.deepEqual(result.files, ['RUDI secrets (GITHUB_TOKEN)']); + }, + ); +}); + +test('which auth status only lets declared populated env keys satisfy a manifest contract', async () => { + await withTempStack( + async (dir) => { + await writeFile( + path.join(dir, '.env'), + 'UNRELATED_VALUE=present\nGITHUB_TOKEN="" # intentionally unset\n', + ); + }, + async (dir) => { + const result = await checkAuth(dir, 'node', { + stack: { + id: 'stack:github', + requires: { + secrets: [{ name: 'GITHUB_TOKEN', required: true }], + }, + }, + async hasSecret() { + return false; + }, + }); + + assert.equal(result.configured, false); + assert.deepEqual(result.files, []); + }, + ); +}); + +test('which auth status accepts a declared populated env key as the manifest credential', async () => { + await withTempStack( + async (dir) => { + await writeFile(path.join(dir, '.env'), 'GITHUB_TOKEN=local-development-token\n'); + }, + async (dir) => { + const result = await checkAuth(dir, 'node', { + stack: { + id: 'stack:github', + requires: { + secrets: [{ name: 'GITHUB_TOKEN', required: true }], + }, + }, + async hasSecret() { + return false; + }, + }); + + assert.equal(result.configured, true); + assert.deepEqual(result.files, ['.env']); + }, + ); +}); + test('which running check treats stack names as literal process filters', () => { const calls = []; const running = checkIfRunning('video-editor"; touch /tmp/rudi-probe #', { diff --git a/src/__tests__/unit/update-command.test.js b/src/__tests__/unit/update-command.test.js index d7d8a0b..c8e0418 100644 --- a/src/__tests__/unit/update-command.test.js +++ b/src/__tests__/unit/update-command.test.js @@ -18,7 +18,7 @@ function createDeps(overrides = {}) { async listInstalled() { calls.push(['listInstalled']); return [ - { id: 'stack:video-editor', kind: 'stack', name: 'video-editor' }, + { id: 'stack:video-editor', kind: 'stack', name: 'video-editor', path: '/tmp/stack-video-editor' }, { id: 'runtime:node', kind: 'runtime', name: 'node' }, { id: 'skill:video-editor', kind: 'skill', name: 'video-editor' }, ]; @@ -27,6 +27,39 @@ function createDeps(overrides = {}) { calls.push(['updatePackage', id, options]); return { success: true, id, path: `/tmp/${id.replace(':', '-')}` }; }, + getPackageLockfilePath(id) { + return `/tmp/rudi-locks/${id.replace(':', '-')}.lock.yaml`; + }, + async createStackUpdateSnapshot(stackPath, options) { + calls.push(['createStackUpdateSnapshot', stackPath, options]); + return { targetPath: stackPath, backupRoot: `${stackPath}.backup` }; + }, + async restoreStackUpdateSnapshot(snapshot) { + calls.push(['restoreStackUpdateSnapshot', snapshot]); + }, + async discardStackUpdateSnapshot(snapshot) { + calls.push(['discardStackUpdateSnapshot', snapshot]); + }, + async loadStackManifest(stackPath) { + calls.push(['loadStackManifest', stackPath]); + return { + version: '2.0.0', + runtime: 'node', + command: ['node', 'dist/index.js'], + requires: { secrets: [{ name: 'STACK_TOKEN', required: false }] }, + }; + }, + async buildStack(stackPath, manifest, options) { + calls.push(['buildStack', stackPath, manifest, options]); + return { built: true }; + }, + validateStack(stackPath, manifest) { + calls.push(['validateStack', stackPath, manifest]); + return { valid: true }; + }, + registerStack(id, stackInfo) { + calls.push(['registerStack', id, stackInfo]); + }, async rebuildToolIndex(options) { calls.push(['rebuildToolIndex', options]); return { indexed: options.stacks.length, failed: 0, index: { byStack: {} } }; @@ -92,7 +125,7 @@ test('resolveUpdateTarget rejects Agent Host updates before package inventory lo ); }); -test('runUpdate updates an explicit stack through core installer and rebuilds its tool index', async () => { +test('runUpdate rebuilds, validates, and refreshes stack metadata before indexing', async () => { const deps = createDeps(); const result = await runUpdate(['stack:video-editor'], {}, deps); @@ -108,7 +141,34 @@ test('runUpdate updates an explicit stack through core installer and rebuilds it [ ['listInstalled'], ['fetchIndex', { force: true }], + ['createStackUpdateSnapshot', '/tmp/stack-video-editor', { + lockfilePath: '/tmp/rudi-locks/stack-video-editor.lock.yaml', + }], ['updatePackage', 'stack:video-editor', { preserveState: false }], + ['loadStackManifest', '/tmp/stack-video-editor'], + ['buildStack', '/tmp/stack-video-editor', { + version: '2.0.0', + runtime: 'node', + command: ['node', 'dist/index.js'], + requires: { secrets: [{ name: 'STACK_TOKEN', required: false }] }, + }, { force: true, verbose: false }], + ['validateStack', '/tmp/stack-video-editor', { + version: '2.0.0', + runtime: 'node', + command: ['node', 'dist/index.js'], + requires: { secrets: [{ name: 'STACK_TOKEN', required: false }] }, + }], + ['registerStack', 'stack:video-editor', { + path: '/tmp/stack-video-editor', + runtime: 'node', + command: ['node', 'dist/index.js'], + secrets: [{ name: 'STACK_TOKEN', required: false }], + version: '2.0.0', + }], + ['discardStackUpdateSnapshot', { + targetPath: '/tmp/stack-video-editor', + backupRoot: '/tmp/stack-video-editor.backup', + }], ['rebuildToolIndex', { stacks: ['stack:video-editor'], timeout: 20000, @@ -174,6 +234,61 @@ test('runUpdate --all skips pinned GitHub packages in both planning and executio }]); }); +test('runUpdate does not index or report a stack update when its rebuild fails', async () => { + const deps = createDeps({ + async buildStack(stackPath, manifest, options) { + deps.calls.push(['buildStack', stackPath, manifest, options]); + throw new Error('compile failed'); + }, + }); + + await assert.rejects( + () => runUpdate(['stack:video-editor'], {}, deps), + /compile failed/, + ); + + assert.equal(deps.calls.some(call => call[0] === 'registerStack'), false); + assert.equal(deps.calls.some(call => call[0] === 'rebuildToolIndex'), false); + assert.equal(deps.calls.some(call => call[0] === 'restoreStackUpdateSnapshot'), true); + assert.equal(deps.calls.some(call => call[0] === 'discardStackUpdateSnapshot'), false); +}); + +test('runUpdate restores a stack snapshot when package download fails', async () => { + const deps = createDeps({ + async updatePackage(id, options) { + deps.calls.push(['updatePackage', id, options]); + return { success: false, id, error: 'download failed' }; + }, + }); + + await assert.rejects( + () => runUpdate(['stack:video-editor'], {}, deps), + /download failed/, + ); + + assert.equal(deps.calls.some(call => call[0] === 'restoreStackUpdateSnapshot'), true); + assert.equal(deps.calls.some(call => call[0] === 'discardStackUpdateSnapshot'), false); +}); + +test('runUpdate reports snapshot cleanup separately after an accepted stack update', async () => { + const deps = createDeps({ + async discardStackUpdateSnapshot(snapshot) { + deps.calls.push(['discardStackUpdateSnapshot', snapshot]); + throw new Error('cleanup denied'); + }, + }); + + const result = await runUpdate(['stack:video-editor'], {}, deps); + + assert.equal(result.updated, 1); + assert.equal(result.failed, 0); + assert.equal(deps.calls.some(call => call[0] === 'rebuildToolIndex'), true); + assert.equal( + deps.calls.some(call => call[0] === 'error' && /cleanup denied/.test(call[1])), + true, + ); +}); + test('runUpdate preserves install-local state only when explicitly requested', async () => { const deps = createDeps(); @@ -240,7 +355,7 @@ test('runUpdate expands an installed stack through Registry related.skills when const deps = createDeps({ async listInstalled() { return [ - { id: 'stack:swe-engineering', kind: 'stack', name: 'swe-engineering' }, + { id: 'stack:swe-engineering', kind: 'stack', name: 'swe-engineering', path: '/tmp/stack-swe-engineering' }, { id: 'skill:swe-compliance-checklist', kind: 'skill', name: 'swe-compliance-checklist', source: 'rudi' }, { id: 'skill:horizontal-engineering-review', kind: 'skill', name: 'horizontal-engineering-review', source: 'rudi' }, { id: 'runtime:node', kind: 'runtime', name: 'node' }, @@ -290,7 +405,7 @@ test('runUpdate skips external native skills that are not installed in RUDI', as const deps = createDeps({ async listInstalled() { return [ - { id: 'stack:swe-engineering', kind: 'stack', name: 'swe-engineering' }, + { id: 'stack:swe-engineering', kind: 'stack', name: 'swe-engineering', path: '/tmp/stack-swe-engineering' }, { id: 'skill:external-companion', kind: 'skill', @@ -331,7 +446,7 @@ test('runUpdate finalizes successful stack work and reports a related-skill fail const deps = createDeps({ async listInstalled() { return [ - { id: 'stack:swe-engineering', kind: 'stack', name: 'swe-engineering' }, + { id: 'stack:swe-engineering', kind: 'stack', name: 'swe-engineering', path: '/tmp/stack-swe-engineering' }, { id: 'skill:rudi-engineering-gate', kind: 'skill', @@ -378,6 +493,54 @@ test('runUpdate finalizes successful stack work and reports a related-skill fail ); }); +test('runUpdate aborts related suite work when the target stack update rolls back', async () => { + const deps = createDeps({ + async listInstalled() { + return [ + { id: 'stack:swe-engineering', kind: 'stack', name: 'swe-engineering', path: '/tmp/stack-swe-engineering' }, + { + id: 'skill:rudi-engineering-gate', + kind: 'skill', + name: 'rudi-engineering-gate', + source: 'rudi', + }, + ]; + }, + async resolvePackage(id) { + return { + id, + kind: 'stack', + relatedSkills: [ + { id: 'skill:rudi-engineering-gate', kind: 'skill', isOperator: false }, + ], + }; + }, + async updatePackage(id, options) { + deps.calls.push(['updatePackage', id, options]); + if (id === 'stack:swe-engineering') { + return { success: false, error: 'target update failed' }; + } + return { success: true, id, path: `/tmp/${id.replace(':', '-')}` }; + }, + }); + + await assert.rejects( + () => runUpdate( + ['stack:swe-engineering'], + { 'with-related-skills': true }, + deps, + ), + /target update failed/, + ); + + assert.deepEqual( + deps.calls.filter((call) => call[0] === 'updatePackage').map((call) => call[1]), + ['stack:swe-engineering'], + ); + assert.equal(deps.calls.some((call) => call[0] === 'restoreStackUpdateSnapshot'), true); + assert.equal(deps.calls.some((call) => call[0] === 'rebuildToolIndex'), false); +}); + test('runUpdate makes requested native projection failures visible and nonzero', async () => { const deps = createDeps({ async listInstalled() { @@ -478,7 +641,7 @@ test('runUpdate dry-run returns the exact suite plan without package or index mu const deps = createDeps({ async listInstalled() { return [ - { id: 'stack:swe-engineering', kind: 'stack', name: 'swe-engineering' }, + { id: 'stack:swe-engineering', kind: 'stack', name: 'swe-engineering', path: '/tmp/stack-swe-engineering' }, { id: 'skill:swe-compliance-checklist', kind: 'skill', name: 'swe-compliance-checklist', source: 'rudi' }, ]; }, @@ -516,7 +679,7 @@ test('runUpdate suite dry-run projects only planned skills to explicitly selecte const deps = createDeps({ async listInstalled() { return [ - { id: 'stack:swe-engineering', kind: 'stack', name: 'swe-engineering' }, + { id: 'stack:swe-engineering', kind: 'stack', name: 'swe-engineering', path: '/tmp/stack-swe-engineering' }, { id: 'skill:swe-compliance-checklist', kind: 'skill', name: 'swe-compliance-checklist', source: 'rudi' }, { id: 'skill:unrelated', kind: 'skill', name: 'unrelated', source: 'rudi' }, ]; diff --git a/src/__tests__/unit/update-stack-snapshot.test.js b/src/__tests__/unit/update-stack-snapshot.test.js new file mode 100644 index 0000000..ef17672 --- /dev/null +++ b/src/__tests__/unit/update-stack-snapshot.test.js @@ -0,0 +1,262 @@ +import assert from 'node:assert/strict'; +import { access, mkdir, mkdtemp, readFile, rename, rm, symlink, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { + copyPathWithoutOverwrite, + createStackUpdateSnapshot, + discardStackUpdateSnapshot, + restoreStackUpdateSnapshot, +} from '../../commands/update.js'; + +test('compensation copies files without deleting the exact staged source', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'rudi-stack-compensation-')); + const stagedPath = path.join(root, 'staged.txt'); + const currentPath = path.join(root, 'current.txt'); + try { + await writeFile(stagedPath, 'exact failed state'); + + await copyPathWithoutOverwrite(stagedPath, currentPath, 'failed file'); + + assert.equal(await readFile(currentPath, 'utf8'), 'exact failed state'); + assert.equal(await readFile(stagedPath, 'utf8'), 'exact failed state'); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('compensation copies directories without deleting the exact staged source', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'rudi-stack-compensation-')); + const stagedPath = path.join(root, 'staged'); + const currentPath = path.join(root, 'current'); + try { + await mkdir(stagedPath, { recursive: true }); + await writeFile(path.join(stagedPath, 'receipt.json'), 'exact failed state'); + + await copyPathWithoutOverwrite(stagedPath, currentPath, 'failed directory'); + + assert.equal(await readFile(path.join(currentPath, 'receipt.json'), 'utf8'), 'exact failed state'); + assert.equal(await readFile(path.join(stagedPath, 'receipt.json'), 'utf8'), 'exact failed state'); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('compensation refuses to overwrite a concurrently recreated destination', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'rudi-stack-compensation-')); + const stagedPath = path.join(root, 'staged.txt'); + const currentPath = path.join(root, 'current.txt'); + try { + await writeFile(stagedPath, 'exact failed state'); + await writeFile(currentPath, 'concurrent state'); + + await assert.rejects( + () => copyPathWithoutOverwrite(stagedPath, currentPath, 'failed file'), + error => error?.code === 'EEXIST', + ); + assert.equal(await readFile(currentPath, 'utf8'), 'concurrent state'); + assert.equal(await readFile(stagedPath, 'utf8'), 'exact failed state'); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('stack update snapshot restores the prior install after a failed mutation', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'rudi-stack-update-')); + const stacksRoot = path.join(root, 'stacks'); + const locksRoot = path.join(root, 'locks'); + const stackPath = path.join(stacksRoot, 'github'); + const lockfilePath = path.join(locksRoot, 'stacks', 'github.lock.yaml'); + try { + await mkdir(path.join(stackPath, 'dist'), { recursive: true }); + await mkdir(path.dirname(lockfilePath), { recursive: true }); + await writeFile(path.join(stackPath, 'dist', 'index.js'), 'old build'); + await writeFile(lockfilePath, 'old lock'); + + const snapshot = await createStackUpdateSnapshot(stackPath, { + lockfilePath, + locksRoot, + stacksRoot, + }); + await writeFile(path.join(stackPath, 'dist', 'index.js'), 'broken build'); + await writeFile(path.join(stackPath, 'partial.js'), 'partial download'); + await writeFile(lockfilePath, 'new lock'); + + await restoreStackUpdateSnapshot(snapshot, { stacksRoot }); + + assert.equal(await readFile(path.join(stackPath, 'dist', 'index.js'), 'utf8'), 'old build'); + assert.equal(await readFile(lockfilePath, 'utf8'), 'old lock'); + await assert.rejects(() => access(path.join(stackPath, 'partial.js'))); + await assert.rejects(() => access(snapshot.backupRoot)); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('stack update snapshot is discarded after a successful mutation', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'rudi-stack-update-')); + const stacksRoot = path.join(root, 'stacks'); + const locksRoot = path.join(root, 'locks'); + const stackPath = path.join(stacksRoot, 'github'); + const lockfilePath = path.join(locksRoot, 'stacks', 'github.lock.yaml'); + try { + await mkdir(stackPath, { recursive: true }); + await writeFile(path.join(stackPath, 'version.txt'), 'old'); + + const snapshot = await createStackUpdateSnapshot(stackPath, { + lockfilePath, + locksRoot, + stacksRoot, + }); + await writeFile(path.join(stackPath, 'version.txt'), 'new'); + await mkdir(path.dirname(lockfilePath), { recursive: true }); + await writeFile(lockfilePath, 'new lock'); + await discardStackUpdateSnapshot(snapshot, { stacksRoot }); + + assert.equal(await readFile(path.join(stackPath, 'version.txt'), 'utf8'), 'new'); + assert.equal(await readFile(lockfilePath, 'utf8'), 'new lock'); + await assert.rejects(() => access(snapshot.backupRoot)); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('stack update snapshot removes migrated state created by a failed update', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'rudi-stack-update-')); + const stacksRoot = path.join(root, 'stacks'); + const stateStacksRoot = path.join(root, 'state', 'stacks'); + const locksRoot = path.join(root, 'locks'); + const stackPath = path.join(stacksRoot, 'github'); + const stateRoot = path.join(stateStacksRoot, 'github'); + const lockfilePath = path.join(locksRoot, 'stacks', 'github.lock.yaml'); + const installRunsPath = path.join(stackPath, 'runs'); + const migratedRunsPath = path.join(stateRoot, 'runs'); + try { + await mkdir(installRunsPath, { recursive: true }); + await writeFile(path.join(installRunsPath, 'receipt.json'), 'accepted receipt'); + + const snapshot = await createStackUpdateSnapshot(stackPath, { + lockfilePath, + locksRoot, + stacksRoot, + stateRoot, + stateStacksRoot, + }); + await mkdir(stateRoot, { recursive: true }); + await rename(installRunsPath, migratedRunsPath); + await rm(stackPath, { recursive: true, force: true }); + await mkdir(stackPath, { recursive: true }); + await writeFile(path.join(stackPath, 'partial.js'), 'failed update'); + + await restoreStackUpdateSnapshot(snapshot, { stacksRoot, stateStacksRoot }); + + assert.equal( + await readFile(path.join(installRunsPath, 'receipt.json'), 'utf8'), + 'accepted receipt', + ); + await assert.rejects(() => access(stateRoot)); + await assert.rejects(() => access(snapshot.backupRoot)); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('stack update snapshot rejects a symlinked ancestor that escapes the managed root', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'rudi-stack-update-')); + const stacksRoot = path.join(root, 'stacks'); + const locksRoot = path.join(root, 'locks'); + const outsideRoot = path.join(root, 'outside'); + const escapedStackPath = path.join(stacksRoot, 'escape', 'github'); + const lockfilePath = path.join(locksRoot, 'stacks', 'github.lock.yaml'); + try { + await mkdir(path.join(outsideRoot, 'github'), { recursive: true }); + await mkdir(stacksRoot, { recursive: true }); + await symlink(outsideRoot, path.join(stacksRoot, 'escape')); + + await assert.rejects( + () => createStackUpdateSnapshot(escapedStackPath, { + lockfilePath, + locksRoot, + stacksRoot, + }), + /symlinked path|outside the managed stack root/i, + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('stack update snapshot preflights every component before replacing the failed install', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'rudi-stack-update-')); + const stacksRoot = path.join(root, 'stacks'); + const stateStacksRoot = path.join(root, 'state', 'stacks'); + const locksRoot = path.join(root, 'locks'); + const stackPath = path.join(stacksRoot, 'github'); + const stateRoot = path.join(stateStacksRoot, 'github'); + const lockfilePath = path.join(locksRoot, 'stacks', 'github.lock.yaml'); + try { + await mkdir(stackPath, { recursive: true }); + await mkdir(stateRoot, { recursive: true }); + await writeFile(path.join(stackPath, 'version.txt'), 'accepted'); + await writeFile(path.join(stateRoot, 'cursor.json'), 'accepted cursor'); + + const snapshot = await createStackUpdateSnapshot(stackPath, { + lockfilePath, + locksRoot, + stacksRoot, + stateRoot, + stateStacksRoot, + }); + await writeFile(path.join(stackPath, 'version.txt'), 'failed install'); + await rm(snapshot.stateSnapshotPath, { recursive: true, force: true }); + + await assert.rejects( + () => restoreStackUpdateSnapshot(snapshot, { stacksRoot, stateStacksRoot }), + /state snapshot/i, + ); + assert.equal(await readFile(path.join(stackPath, 'version.txt'), 'utf8'), 'failed install'); + assert.equal(await readFile(path.join(stateRoot, 'cursor.json'), 'utf8'), 'accepted cursor'); + await access(snapshot.backupRoot); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('stack update snapshot refuses to rewind state changed after migration', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'rudi-stack-update-')); + const stacksRoot = path.join(root, 'stacks'); + const stateStacksRoot = path.join(root, 'state', 'stacks'); + const locksRoot = path.join(root, 'locks'); + const stackPath = path.join(stacksRoot, 'github'); + const stateRoot = path.join(stateStacksRoot, 'github'); + const lockfilePath = path.join(locksRoot, 'stacks', 'github.lock.yaml'); + const installRunsPath = path.join(stackPath, 'runs'); + try { + await mkdir(installRunsPath, { recursive: true }); + await writeFile(path.join(installRunsPath, 'receipt.json'), 'accepted receipt'); + + const snapshot = await createStackUpdateSnapshot(stackPath, { + lockfilePath, + locksRoot, + stacksRoot, + stateRoot, + stateStacksRoot, + }); + await mkdir(stateRoot, { recursive: true }); + await rename(installRunsPath, path.join(stateRoot, 'runs')); + await writeFile(path.join(stateRoot, 'concurrent.json'), 'new state'); + await writeFile(path.join(stackPath, 'failed.js'), 'failed install'); + + await assert.rejects( + () => restoreStackUpdateSnapshot(snapshot, { stacksRoot, stateStacksRoot }), + /state changed during update/i, + ); + assert.equal(await readFile(path.join(stackPath, 'failed.js'), 'utf8'), 'failed install'); + assert.equal(await readFile(path.join(stateRoot, 'concurrent.json'), 'utf8'), 'new state'); + await access(snapshot.backupRoot); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/src/__tests__/unit/versioned-runtime-inspection.test.js b/src/__tests__/unit/versioned-runtime-inspection.test.js new file mode 100644 index 0000000..6d49d1c --- /dev/null +++ b/src/__tests__/unit/versioned-runtime-inspection.test.js @@ -0,0 +1,208 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { test } from 'node:test'; + +const cliPath = fileURLToPath(new URL('../../index.js', import.meta.url)); + +function createExecutable(filePath, version) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, `#!/bin/sh\nprintf '%s\\n' '${version}'\n`, { mode: 0o755 }); +} + +function createVersionedRuntime(rudiHome) { + const runtimeRoot = path.join(rudiHome, 'runtimes', 'node-20-20-2'); + const binaryPath = path.join(runtimeRoot, 'bin', 'node'); + createExecutable(binaryPath, 'v20.20.2'); + fs.writeFileSync(path.join(runtimeRoot, 'manifest.json'), JSON.stringify({ + bins: ['node'], + id: 'runtime:node-20-20-2', + kind: 'runtime', + name: 'Node.js 20.20.2', + version: '20.20.2', + })); + return { binaryPath, runtimeRoot }; +} + +test('check binds a versioned runtime ID to its manifest-declared executable', () => { + const rudiHome = fs.mkdtempSync(path.join(os.tmpdir(), 'rudi-versioned-runtime-check-')); + const { binaryPath } = createVersionedRuntime(rudiHome); + + try { + const result = spawnSync(process.execPath, [ + cliPath, + 'check', + 'runtime:node-20-20-2', + '--json', + ], { + encoding: 'utf8', + env: { ...process.env, RUDI_HOME: rudiHome }, + }); + + assert.equal(result.status, 0, `${result.stderr}\n${result.stdout}`); + assert.deepEqual(JSON.parse(result.stdout), { + authenticated: null, + id: 'runtime:node-20-20-2', + installed: true, + kind: 'runtime', + name: 'node-20-20-2', + path: binaryPath, + ready: true, + source: 'rudi', + version: '20.20.2', + }); + } finally { + fs.rmSync(rudiHome, { force: true, recursive: true }); + } +}); + +test('info separates versioned runtime binaries from preserved shared shims', () => { + const rudiHome = fs.mkdtempSync(path.join(os.tmpdir(), 'rudi-versioned-runtime-info-')); + const { binaryPath } = createVersionedRuntime(rudiHome); + const sharedBinaryPath = path.join(rudiHome, 'runtimes', 'node', 'bin', 'node'); + const shimPath = path.join(rudiHome, 'bins', 'node'); + createExecutable(sharedBinaryPath, 'v20.10.0'); + fs.mkdirSync(path.dirname(shimPath), { recursive: true }); + fs.symlinkSync(sharedBinaryPath, shimPath); + fs.writeFileSync(path.join(rudiHome, 'shim-registry.json'), JSON.stringify({ + node: { + createdAt: '2026-08-31T00:00:00.000Z', + owner: 'runtime:node', + target: sharedBinaryPath, + type: 'symlink', + }, + })); + + try { + const result = spawnSync(process.execPath, [ + cliPath, + 'info', + 'runtime:node-20-20-2', + ], { + encoding: 'utf8', + env: { ...process.env, RUDI_HOME: rudiHome }, + }); + + assert.equal(result.status, 0, `${result.stderr}\n${result.stdout}`); + assert.match(result.stdout, new RegExp(`Installed: \\u2713 ${binaryPath.replaceAll('\\', '\\\\')}`)); + assert.match( + result.stdout, + new RegExp(`Shim: \\u21aa preserved for runtime:node: ${sharedBinaryPath.replaceAll('\\', '\\\\')}`), + ); + assert.doesNotMatch(result.stdout, new RegExp(`Shim: \\u2713 ${sharedBinaryPath.replaceAll('\\', '\\\\')}`)); + } finally { + fs.rmSync(rudiHome, { force: true, recursive: true }); + } +}); + +test('check fails closed when a runtime manifest binary escapes its package root', () => { + const rudiHome = fs.mkdtempSync(path.join(os.tmpdir(), 'rudi-versioned-runtime-escape-')); + const runtimeRoot = path.join(rudiHome, 'runtimes', 'node-20-20-2'); + fs.mkdirSync(runtimeRoot, { recursive: true }); + fs.writeFileSync(path.join(runtimeRoot, 'manifest.json'), JSON.stringify({ + bins: { node: { path: '../../foreign-node' } }, + id: 'runtime:node-20-20-2', + kind: 'runtime', + version: '20.20.2', + })); + createExecutable(path.join(rudiHome, 'foreign-node'), 'v99.99.99'); + + try { + const result = spawnSync(process.execPath, [ + cliPath, + 'check', + 'runtime:node-20-20-2', + '--json', + ], { + encoding: 'utf8', + env: { ...process.env, RUDI_HOME: rudiHome }, + }); + + assert.equal(result.status, 1, `${result.stderr}\n${result.stdout}`); + const output = JSON.parse(result.stdout); + assert.equal(output.installed, false); + assert.equal(output.ready, false); + assert.match(output.error, /binary escapes its package root/); + } finally { + fs.rmSync(rudiHome, { force: true, recursive: true }); + } +}); + +for (const manifestId of [undefined, '']) { + const label = manifestId === undefined ? 'missing' : 'empty'; + + test(`check fails closed when a runtime manifest ID is ${label}`, () => { + const rudiHome = fs.mkdtempSync(path.join(os.tmpdir(), `rudi-versioned-runtime-${label}-id-`)); + const runtimeRoot = path.join(rudiHome, 'runtimes', 'node-20-20-2'); + createExecutable(path.join(runtimeRoot, 'bin', 'node'), 'v20.20.2'); + const manifest = { + bins: ['node'], + kind: 'runtime', + version: '20.20.2', + }; + if (manifestId !== undefined) manifest.id = manifestId; + fs.writeFileSync(path.join(runtimeRoot, 'manifest.json'), JSON.stringify(manifest)); + + try { + const result = spawnSync(process.execPath, [ + cliPath, + 'check', + 'runtime:node-20-20-2', + '--json', + ], { + encoding: 'utf8', + env: { ...process.env, RUDI_HOME: rudiHome }, + }); + + assert.equal(result.status, 1, `${result.stderr}\n${result.stdout}`); + const output = JSON.parse(result.stdout); + assert.equal(output.installed, false); + assert.equal(output.ready, false); + assert.match(output.error, /manifest ID mismatch: expected runtime:node-20-20-2/); + } finally { + fs.rmSync(rudiHome, { force: true, recursive: true }); + } + }); +} + +test('info keeps an exact shared runtime shim attributed to that runtime', () => { + const rudiHome = fs.mkdtempSync(path.join(os.tmpdir(), 'rudi-shared-runtime-info-')); + const runtimeRoot = path.join(rudiHome, 'runtimes', 'node'); + const binaryPath = path.join(runtimeRoot, 'bin', 'node'); + const shimPath = path.join(rudiHome, 'bins', 'node'); + createExecutable(binaryPath, 'v20.10.0'); + fs.writeFileSync(path.join(runtimeRoot, 'manifest.json'), JSON.stringify({ + bins: ['node'], + id: 'runtime:node', + kind: 'runtime', + name: 'Node.js', + version: '20.10.0', + })); + fs.mkdirSync(path.dirname(shimPath), { recursive: true }); + fs.symlinkSync(binaryPath, shimPath); + fs.writeFileSync(path.join(rudiHome, 'shim-registry.json'), JSON.stringify({ + node: { + createdAt: '2026-08-31T00:00:00.000Z', + owner: 'runtime:node', + target: binaryPath, + type: 'symlink', + }, + })); + + try { + const result = spawnSync(process.execPath, [cliPath, 'info', 'runtime:node'], { + encoding: 'utf8', + env: { ...process.env, RUDI_HOME: rudiHome }, + }); + + assert.equal(result.status, 0, `${result.stderr}\n${result.stdout}`); + assert.match(result.stdout, new RegExp(`Installed: \\u2713 ${binaryPath.replaceAll('\\', '\\\\')}`)); + assert.match(result.stdout, new RegExp(`Shim: \\u2713 ${binaryPath.replaceAll('\\', '\\\\')}`)); + assert.match(result.stdout, /Type: symlink \(this package\)/); + } finally { + fs.rmSync(rudiHome, { force: true, recursive: true }); + } +}); diff --git a/src/commands/check.js b/src/commands/check.js index 185b860..2794024 100644 --- a/src/commands/check.js +++ b/src/commands/check.js @@ -17,6 +17,7 @@ import { PATHS, isPackageInstalled, getPackagePath, checkStackLifecycle, readRud import fs from 'fs'; import path from 'path'; import { inspectAgentHost } from '../agent-host/preflight.js'; +import { inspectRuntimeInstall } from '../runtime-inspection.js'; import { createWhichCommand, runCommand, runCommandPlan } from '../utils/subprocess.js'; const KNOWN_AGENT_HOSTS = new Set(['antigravity', 'claude', 'codex', 'gemini', 'google']); @@ -148,19 +149,31 @@ export async function cmdCheck(args, flags) { } case 'runtime': { - // Check RUDI location first - const rudiPath = path.join(PATHS.runtimes, name, 'bin', name); - if (fs.existsSync(rudiPath)) { + const inspected = inspectRuntimeInstall(`runtime:${name}`); + if (inspected.installed) { result.installed = true; - result.path = rudiPath; - result.version = getVersion(rudiPath); + result.source = 'rudi'; + result.path = inspected.primaryBinary.path; + result.version = getVersion(inspected.primaryBinary.path); } else { - // Check global - const globalPath = findGlobalBinary(name); - if (globalPath) { + // Preserve legacy unmanifested runtimes whose executable matched the + // package name, but fail closed when an installed manifest is invalid. + const legacyRudiPath = path.join(PATHS.runtimes, name, 'bin', name); + if (!inspected.manifestPresent && fs.existsSync(legacyRudiPath)) { result.installed = true; - result.path = globalPath; - result.version = getVersion(globalPath); + result.source = 'rudi'; + result.path = legacyRudiPath; + result.version = getVersion(legacyRudiPath); + } else if (!inspected.rootExists) { + const globalPath = findGlobalBinary(name); + if (globalPath) { + result.installed = true; + result.source = 'global'; + result.path = globalPath; + result.version = getVersion(globalPath); + } + } else if (inspected.error) { + result.error = inspected.error; } } result.ready = result.installed; diff --git a/src/commands/info.js b/src/commands/info.js index 4469eb4..f08bc09 100644 --- a/src/commands/info.js +++ b/src/commands/info.js @@ -14,8 +14,17 @@ import fs from 'fs'; import path from 'path'; import { getPackagePath, parsePackageId, PATHS } from '@learnrudi/env'; import { getShimOwner, validateShim } from '@learnrudi/core'; +import { inspectRuntimeInstall } from '../runtime-inspection.js'; import { printPackageLifecycle } from './package-lifecycle.js'; +function resolvesToSameFile(leftPath, rightPath) { + try { + return fs.realpathSync(leftPath) === fs.realpathSync(rightPath); + } catch { + return false; + } +} + export async function cmdInfo(args, flags) { const pkgId = args[0]; @@ -46,6 +55,15 @@ export async function cmdInfo(args, flags) { } } + let runtimeInspection = null; + if (kind === 'runtime') { + runtimeInspection = inspectRuntimeInstall(pkgId); + if (runtimeInspection.error) { + throw new Error(runtimeInspection.error); + } + manifest = runtimeInspection.manifest; + } + console.log(`\nPackage: ${pkgId}`); console.log('─'.repeat(50)); @@ -95,12 +113,15 @@ export async function cmdInfo(args, flags) { } // Binaries and shims - const bins = manifest?.bins || manifest?.binaries || []; + const bins = runtimeInspection + ? runtimeInspection.binaries.map(binary => binary.name) + : manifest?.bins || manifest?.binaries || []; if (bins.length > 0) { console.log(`\nBinaries (${bins.length}):`); console.log('─'.repeat(50)); for (const bin of bins) { + const installedRuntimeBinary = runtimeInspection?.binaries.find(binary => binary.name === bin); const shimPath = path.join(PATHS.bins, bin); const validation = validateShim(bin); const ownership = getShimOwner(bin); @@ -108,13 +129,23 @@ export async function cmdInfo(args, flags) { let shimStatus = '✗ no shim'; if (fs.existsSync(shimPath)) { if (validation.valid) { - shimStatus = `✓ ${validation.target}`; + if ( + installedRuntimeBinary && + !resolvesToSameFile(validation.target, installedRuntimeBinary.resolvedPath) + ) { + shimStatus = `↪ preserved for ${ownership?.owner || 'another package'}: ${validation.target}`; + } else { + shimStatus = `✓ ${validation.target}`; + } } else { shimStatus = `⚠ broken: ${validation.error}`; } } console.log(` ${bin}:`); + if (installedRuntimeBinary) { + console.log(` Installed: ✓ ${installedRuntimeBinary.path}`); + } console.log(` Shim: ${shimStatus}`); if (ownership) { diff --git a/src/commands/install.js b/src/commands/install.js index 701f715..d8c1f73 100644 --- a/src/commands/install.js +++ b/src/commands/install.js @@ -37,7 +37,7 @@ import { syncClaudeSkills, syncCodexSkills } from './skills.js'; /** * Load manifest from installed stack path */ -async function loadManifest(installPath) { +export async function loadManifest(installPath) { const manifestPath = path.join(installPath, 'manifest.json'); try { const content = await fs.readFile(manifestPath, 'utf-8'); @@ -78,11 +78,11 @@ function getBundledBinary(runtime, binary) { return binary; } -function getStackRuntime(manifest) { +export function getStackRuntime(manifest) { return manifest?.runtime || manifest?.mcp?.runtime || 'node'; } -function getStackCommand(manifest) { +export function getStackCommand(manifest) { let command = manifest?.command; if (!command || command.length === 0) { @@ -200,7 +200,7 @@ async function installDependencies(stackPath, manifest, options = {}) { } } -function getManifestSecrets(manifest) { +export function getManifestSecrets(manifest) { return manifest?.requires?.secrets || manifest?.secrets || []; } @@ -661,7 +661,7 @@ export function validateExternalStackCommand(stackPath, manifest) { * Validate that a stack's entry point exists * @returns {{ valid: boolean, error?: string }} */ -function validateStackEntryPoint(stackPath, manifest) { +export function validateStackEntryPoint(stackPath, manifest) { const runtime = getStackRuntime(manifest); // Binary stacks: validate binary exists and is executable @@ -704,7 +704,14 @@ function validateStackEntryPoint(stackPath, manifest) { } export async function buildStackIfNeeded(stackPath, manifest, options = {}) { - const { nodeProject, verbose = false, allowScripts = true } = options; + const { + allowScripts = true, + force = false, + nodeProject, + npmCommand, + runBuildCommand = runCommand, + verbose = false, + } = options; const runtime = getStackRuntime(manifest); if (runtime !== 'node') { @@ -716,7 +723,10 @@ export async function buildStackIfNeeded(stackPath, manifest, options = {}) { return { built: false, reason: entryPoint.error }; } - if (!entryPoint.entryPath || fsSync.existsSync(entryPoint.entryPath)) { + if ( + !entryPoint.entryPath || + (fsSync.existsSync(entryPoint.entryPath) && !force) + ) { return { built: false, reason: 'Entry point already present' }; } @@ -739,11 +749,11 @@ export async function buildStackIfNeeded(stackPath, manifest, options = {}) { ); } - const npmCmd = getBundledBinary('node', 'npm'); + const npmCmd = npmCommand || getBundledBinary('node', 'npm'); console.log(` Building stack...`); try { - runCommand(npmCmd, ['run', 'build'], { + runBuildCommand(npmCmd, ['run', 'build'], { cwd: project.root, stdio: verbose ? 'inherit' : 'pipe', }); diff --git a/src/commands/update.js b/src/commands/update.js index dcb8ca4..887aa1b 100644 --- a/src/commands/update.js +++ b/src/commands/update.js @@ -2,13 +2,28 @@ * Update command - update installed packages from the registry. */ +import { createHash } from 'node:crypto'; +import { constants as fsConstants } from 'node:fs'; +import * as fs from 'node:fs/promises'; +import * as path from 'path'; import { + addStack, + getLockfilePath, indexAllStacks, listInstalled, resolvePackage as coreResolvePackage, updatePackage as coreUpdatePackage, } from '@learnrudi/core'; +import { PATHS } from '@learnrudi/env'; import { fetchIndex } from '@learnrudi/registry-client'; +import { + buildStackIfNeeded, + getManifestSecrets, + getStackCommand, + getStackRuntime, + loadManifest, + validateStackEntryPoint, +} from './install.js'; import { buildRelatedSkillUpdatePlan } from './related-skills.js'; import { parseNativeSkillSyncTargets, @@ -25,11 +40,476 @@ function rebuildToolIndex(options = {}) { }); } +async function resolveManagedPath(candidate, rootInput, options) { + const { candidateLabel, rootLabel, createRoot = false } = options; + if (typeof candidate !== 'string' || candidate.trim() !== candidate || !candidate) { + throw new Error(`${candidateLabel} is required for transactional update`); + } + + const root = path.resolve(rootInput); + const targetPath = path.resolve(candidate); + if (targetPath === root || !targetPath.startsWith(`${root}${path.sep}`)) { + throw new Error(`Refusing to snapshot ${candidateLabel.toLowerCase()} outside the managed ${rootLabel}: ${candidate}`); + } + + if (createRoot) { + await fs.mkdir(root, { recursive: true }); + } + const rootStat = await fs.lstat(root); + if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) { + throw new Error(`Managed ${rootLabel} must be a real directory: ${root}`); + } + + const relative = path.relative(root, targetPath); + let current = root; + for (const segment of relative.split(path.sep)) { + current = path.join(current, segment); + try { + const stat = await fs.lstat(current); + if (stat.isSymbolicLink()) { + throw new Error(`Refusing symlinked path within managed ${rootLabel}: ${current}`); + } + } catch (error) { + if (error.code === 'ENOENT') break; + throw error; + } + } + + return { root, targetPath }; +} + +function resolveManagedStackPath(stackPath, stacksRoot = PATHS.stacks, options = {}) { + return resolveManagedPath(stackPath, stacksRoot, { + candidateLabel: 'Installed stack path', + rootLabel: 'stack root', + ...options, + }); +} + +function resolveManagedLockfilePath(lockfilePath, locksRoot = PATHS.locks, options = {}) { + return resolveManagedPath(lockfilePath, locksRoot, { + candidateLabel: 'Stack lockfile path', + rootLabel: 'lock root', + ...options, + }); +} + +function resolveManagedStackStatePath( + stateRoot, + stateStacksRoot = path.join(PATHS.home, 'state', 'stacks'), + options = {}, +) { + return resolveManagedPath(stateRoot, stateStacksRoot, { + candidateLabel: 'Stack state path', + rootLabel: 'stack state root', + ...options, + }); +} + +async function buildTreeManifest(rootPath, prefix = '') { + const entries = []; + + async function visit(currentPath, relativePath) { + let stat; + try { + stat = await fs.lstat(currentPath); + } catch (error) { + if (error.code === 'ENOENT' && relativePath === prefix) return; + throw error; + } + + const manifestPath = relativePath || '.'; + if (stat.isSymbolicLink()) { + entries.push([manifestPath, 'symlink', await fs.readlink(currentPath)]); + return; + } + if (stat.isDirectory()) { + entries.push([manifestPath, 'directory', '']); + const names = await fs.readdir(currentPath); + names.sort(); + for (const name of names) { + const childRelative = relativePath ? path.join(relativePath, name) : name; + await visit(path.join(currentPath, name), childRelative); + } + return; + } + if (stat.isFile()) { + const digest = createHash('sha256').update(await fs.readFile(currentPath)).digest('hex'); + entries.push([manifestPath, 'file', digest]); + return; + } + throw new Error(`Unsupported state entry type: ${currentPath}`); + } + + await visit(rootPath, prefix); + return entries.sort((left, right) => left[0].localeCompare(right[0])); +} + +function mergeExpectedStateManifest(initialManifest, migratedRunsManifest) { + if (migratedRunsManifest.length === 0) return initialManifest; + const byPath = new Map(initialManifest.map(entry => [entry[0], entry])); + if (!byPath.has('.')) byPath.set('.', ['.', 'directory', '']); + + for (const entry of migratedRunsManifest) { + const existing = byPath.get(entry[0]); + if (existing) { + if (existing[1] !== 'directory' || entry[1] !== 'directory') return null; + continue; + } + byPath.set(entry[0], entry); + } + + return [...byPath.values()].sort((left, right) => left[0].localeCompare(right[0])); +} + +function validTreeManifest(value) { + return Array.isArray(value) && value.every(entry => ( + Array.isArray(entry) && + entry.length === 3 && + entry.every(part => typeof part === 'string') + )); +} + +function treeManifestsEqual(left, right) { + return JSON.stringify(left) === JSON.stringify(right); +} + +async function assertSnapshotComponent(componentPath, type, label) { + let stat; + try { + stat = await fs.lstat(componentPath); + } catch (error) { + if (error.code === 'ENOENT') throw new Error(`Missing ${label}: ${componentPath}`); + throw error; + } + const validType = type === 'directory' ? stat.isDirectory() : stat.isFile(); + if (!validType || stat.isSymbolicLink()) { + throw new Error(`Invalid ${label}: ${componentPath}`); + } +} + +export async function copyPathWithoutOverwrite(sourcePath, destinationPath, label) { + const sourceStat = await fs.lstat(sourcePath); + if (sourceStat.isSymbolicLink()) { + throw new Error(`Refusing to copy symlinked ${label}: ${sourcePath}`); + } + await fs.mkdir(path.dirname(destinationPath), { recursive: true }); + + const expectedManifest = await buildTreeManifest(sourcePath); + if (sourceStat.isFile()) { + await fs.copyFile(sourcePath, destinationPath, fsConstants.COPYFILE_EXCL); + } else if (sourceStat.isDirectory()) { + await fs.cp(sourcePath, destinationPath, { + errorOnExist: true, + force: false, + preserveTimestamps: true, + recursive: true, + }); + } else { + throw new Error(`Unsupported ${label} type: ${sourcePath}`); + } + + const copiedManifest = await buildTreeManifest(destinationPath); + if (!treeManifestsEqual(copiedManifest, expectedManifest)) { + throw new Error( + `Concurrent ${label} mutation detected at ${destinationPath}; ` + + `exact source retained at ${sourcePath}`, + ); + } +} + +async function validateStackUpdateSnapshot(snapshot, options = {}) { + if (!snapshot || typeof snapshot !== 'object') { + throw new Error('Invalid stack update snapshot'); + } + + const { root, targetPath } = await resolveManagedStackPath( + snapshot.targetPath, + options.stacksRoot || PATHS.stacks, + ); + const { targetPath: lockfilePath } = await resolveManagedLockfilePath( + snapshot.lockfilePath, + snapshot.locksRoot || PATHS.locks, + ); + const { targetPath: stateRoot } = await resolveManagedStackStatePath( + snapshot.stateRoot, + options.stateStacksRoot || snapshot.stateStacksRoot || path.join(PATHS.home, 'state', 'stacks'), + ); + const backupRoot = path.resolve(String(snapshot.backupRoot || '')); + const snapshotPath = path.resolve(String(snapshot.snapshotPath || '')); + const lockfileSnapshotPath = path.resolve(String(snapshot.lockfileSnapshotPath || '')); + const stateSnapshotPath = path.resolve(String(snapshot.stateSnapshotPath || '')); + const expectedPrefix = `.${path.basename(targetPath)}.update-backup-`; + if ( + path.dirname(backupRoot) !== root || + !path.basename(backupRoot).startsWith(expectedPrefix) || + snapshotPath !== path.join(backupRoot, 'snapshot') || + lockfileSnapshotPath !== path.join(backupRoot, 'lockfile') || + stateSnapshotPath !== path.join(backupRoot, 'state') + ) { + throw new Error('Invalid stack update snapshot paths'); + } + await assertSnapshotComponent(backupRoot, 'directory', 'stack update backup root'); + if (!validTreeManifest(snapshot.stateInitialManifest)) { + throw new Error('Invalid initial state manifest in stack update snapshot'); + } + if (snapshot.stateExpectedManifest !== null && !validTreeManifest(snapshot.stateExpectedManifest)) { + throw new Error('Invalid expected state manifest in stack update snapshot'); + } + + return { + backupRoot, + lockfileExisted: snapshot.lockfileExisted === true, + lockfilePath, + lockfileSnapshotPath, + snapshotPath, + stateRoot, + stateRootExisted: snapshot.stateRootExisted === true, + stateExpectedManifest: snapshot.stateExpectedManifest, + stateInitialManifest: snapshot.stateInitialManifest, + stateSnapshotPath, + targetPath, + }; +} + +export async function createStackUpdateSnapshot(stackPath, options = {}) { + const { root, targetPath } = await resolveManagedStackPath(stackPath, options.stacksRoot); + const locksRoot = path.resolve(options.locksRoot || PATHS.locks); + const { targetPath: lockfilePath } = await resolveManagedLockfilePath( + options.lockfilePath, + locksRoot, + { createRoot: true }, + ); + const stateStacksRoot = path.resolve( + options.stateStacksRoot || ( + options.stacksRoot + ? path.join(path.dirname(root), 'state', 'stacks') + : path.join(PATHS.home, 'state', 'stacks') + ), + ); + const { targetPath: stateRoot } = await resolveManagedStackStatePath( + options.stateRoot || path.join(stateStacksRoot, path.basename(targetPath)), + stateStacksRoot, + { createRoot: true }, + ); + const stackStat = await fs.lstat(targetPath); + if (!stackStat.isDirectory() || stackStat.isSymbolicLink()) { + throw new Error(`Installed stack path must be a real directory: ${stackPath}`); + } + + const backupRoot = await fs.mkdtemp( + path.join(root, `.${path.basename(targetPath)}.update-backup-`), + ); + const snapshotPath = path.join(backupRoot, 'snapshot'); + const lockfileSnapshotPath = path.join(backupRoot, 'lockfile'); + const stateSnapshotPath = path.join(backupRoot, 'state'); + let lockfileExisted = false; + let stateRootExisted = false; + const stateInitialManifest = await buildTreeManifest(stateRoot); + const migratedRunsManifest = await buildTreeManifest(path.join(targetPath, 'runs'), 'runs'); + const stateExpectedManifest = mergeExpectedStateManifest( + stateInitialManifest, + migratedRunsManifest, + ); + try { + await fs.chmod(backupRoot, 0o700); + await fs.cp(targetPath, snapshotPath, { + errorOnExist: true, + force: false, + preserveTimestamps: true, + recursive: true, + }); + try { + const lockfileStat = await fs.lstat(lockfilePath); + if (!lockfileStat.isFile() || lockfileStat.isSymbolicLink()) { + throw new Error(`Stack lockfile path must be a real file: ${lockfilePath}`); + } + await fs.copyFile(lockfilePath, lockfileSnapshotPath); + lockfileExisted = true; + } catch (error) { + if (error.code !== 'ENOENT') throw error; + } + try { + const stateStat = await fs.lstat(stateRoot); + if (!stateStat.isDirectory() || stateStat.isSymbolicLink()) { + throw new Error(`Stack state path must be a real directory: ${stateRoot}`); + } + await fs.cp(stateRoot, stateSnapshotPath, { + errorOnExist: true, + force: false, + preserveTimestamps: true, + recursive: true, + }); + stateRootExisted = true; + } catch (error) { + if (error.code !== 'ENOENT') throw error; + } + } catch (error) { + await fs.rm(backupRoot, { force: true, recursive: true }); + throw error; + } + + return { + backupRoot, + lockfileExisted, + lockfilePath, + lockfileSnapshotPath, + locksRoot, + snapshotPath, + stateRoot, + stateRootExisted, + stateExpectedManifest, + stateInitialManifest, + stateSnapshotPath, + stateStacksRoot, + targetPath, + }; +} + +export async function restoreStackUpdateSnapshot(snapshot, options = {}) { + const { + backupRoot, + lockfileExisted, + lockfilePath, + lockfileSnapshotPath, + snapshotPath, + stateRoot, + stateRootExisted, + stateExpectedManifest, + stateInitialManifest, + stateSnapshotPath, + targetPath, + } = await validateStackUpdateSnapshot(snapshot, options); + + await assertSnapshotComponent(snapshotPath, 'directory', 'stack snapshot'); + if (lockfileExisted) { + await assertSnapshotComponent(lockfileSnapshotPath, 'file', 'lockfile snapshot'); + } + if (stateRootExisted) { + await assertSnapshotComponent(stateSnapshotPath, 'directory', 'state snapshot'); + } + + const components = [ + { + currentPath: stateRoot, + existedBefore: stateRootExisted, + label: 'state', + snapshotPath: stateSnapshotPath, + }, + { + currentPath: targetPath, + existedBefore: true, + label: 'install', + snapshotPath, + }, + { + currentPath: lockfilePath, + existedBefore: lockfileExisted, + label: 'lockfile', + snapshotPath: lockfileSnapshotPath, + }, + ]; + const stateComponent = components[0]; + + for (const component of components) { + component.stagedPath = path.join(backupRoot, `failed-${component.label}`); + component.staged = false; + component.promoted = false; + } + + try { + for (const component of components) { + try { + const currentStat = await fs.lstat(component.currentPath); + if (currentStat.isSymbolicLink()) { + throw new Error(`Refusing to stage symlinked ${component.label}: ${component.currentPath}`); + } + await fs.rename(component.currentPath, component.stagedPath); + component.staged = true; + } catch (error) { + if (error.code !== 'ENOENT') throw error; + } + } + + const stagedStateManifest = stateComponent.staged + ? await buildTreeManifest(stateComponent.stagedPath) + : []; + const stateMatchesInitial = treeManifestsEqual(stagedStateManifest, stateInitialManifest); + const stateMatchesExpected = ( + stateExpectedManifest !== null && + treeManifestsEqual(stagedStateManifest, stateExpectedManifest) + ); + if (!stateMatchesInitial && !stateMatchesExpected) { + throw new Error(`Stack state changed during update; refusing to rewind: ${stateRoot}`); + } + + for (const component of components) { + if (!component.existedBefore) continue; + await fs.mkdir(path.dirname(component.currentPath), { recursive: true }); + await fs.rename(component.snapshotPath, component.currentPath); + component.promoted = true; + } + } catch (error) { + const compensationErrors = []; + for (const component of [...components].reverse()) { + if (component.promoted) { + try { + await copyPathWithoutOverwrite( + component.currentPath, + component.snapshotPath, + `accepted ${component.label}`, + ); + } catch (compensationError) { + compensationErrors.push(compensationError.message); + } + compensationErrors.push( + `Rollback could not atomically restore the failed ${component.label}; ` + + `accepted data remains at ${component.currentPath} and failed data remains at ${component.stagedPath}`, + ); + continue; + } + if (component.staged) { + try { + await copyPathWithoutOverwrite( + component.stagedPath, + component.currentPath, + `failed ${component.label}`, + ); + } catch (compensationError) { + compensationErrors.push(compensationError.message); + } + } + } + if (compensationErrors.length > 0) { + throw new Error( + `${error.message}; rollback compensation failed: ${compensationErrors.join('; ')}`, + { cause: error }, + ); + } + throw error; + } + + await fs.rm(backupRoot, { force: true, recursive: true }); +} + +export async function discardStackUpdateSnapshot(snapshot, options = {}) { + const { backupRoot } = await validateStackUpdateSnapshot(snapshot, options); + await fs.rm(backupRoot, { force: true, recursive: true }); +} + const defaultDependencies = { fetchIndex, listInstalled, resolvePackage: coreResolvePackage, updatePackage: coreUpdatePackage, + getPackageLockfilePath: getLockfilePath, + createStackUpdateSnapshot, + restoreStackUpdateSnapshot, + discardStackUpdateSnapshot, + loadStackManifest: loadManifest, + buildStack: buildStackIfNeeded, + validateStack: validateStackEntryPoint, + registerStack: addStack, rebuildToolIndex, log: console.log, error: console.error, @@ -160,15 +640,77 @@ function logSkillProjectionFailures(skillProjection, deps) { async function updateOnePackage(pkg, flags, deps) { deps.log(`Updating ${pkg.id}...`); - const result = await deps.updatePackage(pkg.id, { - preserveState: shouldPreserveInstallState(flags), - }); - if (!result?.success) { - throw new Error(result?.error || `Failed to update ${pkg.id}`); + const kind = pkg.kind || packageKindFromId(pkg.id); + const snapshot = kind === 'stack' + ? await deps.createStackUpdateSnapshot(pkg.path, { + lockfilePath: deps.getPackageLockfilePath(pkg.id), + }) + : null; + let result; + + try { + result = await deps.updatePackage(pkg.id, { + preserveState: shouldPreserveInstallState(flags), + }); + if (!result?.success) { + throw new Error(result?.error || `Failed to update ${pkg.id}`); + } + + if (kind === 'stack') { + if (path.resolve(result.path) !== path.resolve(snapshot.targetPath)) { + throw new Error(`Updated stack path changed unexpectedly for ${pkg.id}`); + } + + const manifest = await deps.loadStackManifest(result.path); + if (!manifest) { + throw new Error(`Stack manifest not found after updating ${pkg.id}`); + } + + await deps.buildStack(result.path, manifest, { + force: true, + verbose: Boolean(flags.verbose), + }); + + const validation = deps.validateStack(result.path, manifest); + if (!validation.valid) { + throw new Error(`Stack validation failed: ${validation.error}`); + } + + deps.registerStack(pkg.id, { + path: result.path, + runtime: getStackRuntime(manifest), + command: getStackCommand(manifest), + secrets: getManifestSecrets(manifest), + version: manifest.version, + }); + } + } catch (error) { + if (snapshot) { + try { + await deps.restoreStackUpdateSnapshot(snapshot); + } catch (rollbackError) { + throw new Error( + `${error.message}; stack rollback failed: ${rollbackError.message}`, + { cause: error }, + ); + } + } + throw error; } + + if (snapshot) { + try { + await deps.discardStackUpdateSnapshot(snapshot); + } catch (cleanupError) { + deps.error( + ` ! ${pkg.id}: update applied, but snapshot cleanup failed: ${cleanupError.message}`, + ); + } + } + return { id: pkg.id, - kind: pkg.kind || packageKindFromId(pkg.id), + kind, result, }; } @@ -298,6 +840,9 @@ export async function runUpdate(args = [], flags = {}, deps = defaultDependencie const updated = await updateOnePackage(pkg, flags, deps); updatedPackages.push(updated); } catch (error) { + if (pkg.id === target.id) { + throw error; + } failedPackages.push({ id: pkg.id, error: error.message }); deps.error(` x ${pkg.id}: ${error.message}`); } diff --git a/src/commands/which.js b/src/commands/which.js index 6586b6f..9a8ec0d 100644 --- a/src/commands/which.js +++ b/src/commands/which.js @@ -11,6 +11,7 @@ import * as fs from 'fs/promises'; import * as path from 'path'; import { listInstalled } from '@learnrudi/core'; import { PATHS } from '@learnrudi/env'; +import { hasSecret as defaultHasSecret } from '@learnrudi/secrets'; import { formatOperatorSkillLine, formatRelatedSkillsLine } from './related-skills.js'; import { runCommand as defaultRunCommand } from '../utils/subprocess.js'; @@ -52,7 +53,7 @@ export async function cmdWhich(args, flags) { const runtimeInfo = await detectRuntime(stackPath); // Check auth status - const authStatus = await checkAuth(stackPath, runtimeInfo.runtime); + const authStatus = await checkAuth(stackPath, runtimeInfo.runtime, { stack }); // Check if MCP server is running const isRunning = checkIfRunning(stack.name || stack.id.replace('stack:', '')); @@ -238,24 +239,90 @@ export async function checkAuth(stackPath, runtime, options = {}) { `state/stacks/${stackName}`, ); - // Check for .env file (API key stacks) + const envCredentialNames = new Set(); const envPath = path.join(stackPath, '.env'); try { const envContent = await fs.readFile(envPath, 'utf-8'); - // Check if .env has actual values (not just placeholders) - const hasValues = envContent.split('\n').some(line => { + for (const line of envContent.split('\n')) { const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith('#')) return false; - const [key, value] = trimmed.split('='); - return value && value.trim() && !value.includes('YOUR_') && !value.includes('your_'); - }); + if (!trimmed || trimmed.startsWith('#')) continue; + + const separatorIndex = trimmed.indexOf('='); + if (separatorIndex <= 0) continue; + + const name = trimmed.slice(0, separatorIndex).trim(); + let value = trimmed.slice(separatorIndex + 1).trim(); + const quotedValue = value.match(/^(["'])(.*?)\1(?:\s*#.*)?$/); + if (quotedValue) { + value = quotedValue[2].trim(); + } else { + value = value.replace(/\s+#.*$/, '').trim(); + } + if ( + /^[A-Za-z_][A-Za-z0-9_]*$/.test(name) && + value && + !value.includes('YOUR_') && + !value.includes('your_') + ) { + envCredentialNames.add(name); + } + } + } catch { + // No readable .env file. + } + + const manifestSecrets = options.stack?.requires?.secrets || options.stack?.secrets || []; + if (Array.isArray(manifestSecrets) && manifestSecrets.length > 0) { + const hasSecret = options.hasSecret || defaultHasSecret; + let requiredCount = 0; + let requiredPresent = 0; + let presentCount = 0; + let envCredentialPresent = false; + + for (const [index, secret] of manifestSecrets.entries()) { + const rawName = typeof secret === 'string' + ? secret + : secret?.name || secret?.key; + if ( + typeof rawName !== 'string' || + !rawName || + rawName !== rawName.trim() + ) { + throw new Error(`Invalid stack secret name at index ${index}`); + } - if (hasValues) { - authFiles.push('.env'); + const name = rawName; + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) { + throw new Error(`Invalid stack secret name at index ${index}`); + } + + const required = typeof secret !== 'object' || secret === null || secret.required !== false; + if (required) requiredCount += 1; + + const storedCredentialPresent = await hasSecret(name); + const localEnvCredentialPresent = envCredentialNames.has(name); + if (storedCredentialPresent || localEnvCredentialPresent) { + presentCount += 1; + if (required) requiredPresent += 1; + if (storedCredentialPresent) { + authFiles.push(`RUDI secrets (${name})`); + } + if (localEnvCredentialPresent) { + envCredentialPresent = true; + } + } + } + + if ( + (requiredCount > 0 && requiredPresent === requiredCount) || + (requiredCount === 0 && presentCount === manifestSecrets.length) + ) { configured = true; + if (envCredentialPresent) authFiles.push('.env'); } - } catch { - // No .env file + } else if (envCredentialNames.size > 0) { + authFiles.push('.env'); + configured = true; } if (configured) { diff --git a/src/runtime-inspection.js b/src/runtime-inspection.js new file mode 100644 index 0000000..5a1c69f --- /dev/null +++ b/src/runtime-inspection.js @@ -0,0 +1,102 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { getPackagePath } from '@learnrudi/env'; + +function isWithinRoot(rootPath, candidatePath) { + const relative = path.relative(rootPath, candidatePath); + return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative)); +} + +function declaredRuntimeBins(manifest) { + if (Array.isArray(manifest?.bins)) { + return manifest.bins.map(name => ({ name, relativePath: path.join('bin', name) })); + } + + if (manifest?.bins && typeof manifest.bins === 'object') { + return Object.entries(manifest.bins).map(([name, descriptor]) => ({ + name, + relativePath: descriptor?.path || path.join('bin', name), + })); + } + + return []; +} + +export function inspectRuntimeInstall(packageId) { + const installRoot = getPackagePath(packageId); + const manifestPath = path.join(installRoot, 'manifest.json'); + const rootExists = fs.existsSync(installRoot); + const resolvedInstallRoot = rootExists ? fs.realpathSync(installRoot) : installRoot; + const manifestPresent = fs.existsSync(manifestPath); + + if (!manifestPresent) { + return { + binaries: [], + error: rootExists ? 'Installed runtime manifest is missing' : null, + installRoot, + installed: false, + manifest: null, + manifestPresent: false, + rootExists, + }; + } + + try { + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + if (manifest.id !== packageId) { + const actualId = Object.hasOwn(manifest, 'id') ? JSON.stringify(manifest.id) : '(missing)'; + throw new Error(`Installed runtime manifest ID mismatch: expected ${packageId}, got ${actualId}`); + } + + const binaries = declaredRuntimeBins(manifest).map(({ name, relativePath }) => { + if (typeof name !== 'string' || !name || typeof relativePath !== 'string' || !relativePath) { + throw new Error('Installed runtime manifest contains an invalid binary declaration'); + } + + const binaryPath = path.resolve(installRoot, relativePath); + if (!isWithinRoot(installRoot, binaryPath)) { + throw new Error(`Installed runtime binary escapes its package root: ${name}`); + } + if (!fs.existsSync(binaryPath)) { + throw new Error(`Installed runtime binary is missing: ${name}`); + } + + const resolvedPath = fs.realpathSync(binaryPath); + if (!isWithinRoot(resolvedInstallRoot, resolvedPath)) { + throw new Error(`Installed runtime binary resolves outside its package root: ${name}`); + } + if (!fs.statSync(resolvedPath).isFile()) { + throw new Error(`Installed runtime binary is not a regular file: ${name}`); + } + fs.accessSync(resolvedPath, fs.constants.X_OK); + + return { name, path: binaryPath, resolvedPath }; + }); + + if (binaries.length === 0) { + throw new Error('Installed runtime manifest declares no binaries'); + } + + return { + binaries, + error: null, + installRoot, + installed: true, + manifest, + manifestPresent: true, + primaryBinary: binaries[0], + rootExists: true, + }; + } catch (error) { + return { + binaries: [], + error: error.message, + installRoot, + installed: false, + manifest: null, + manifestPresent: true, + rootExists: true, + }; + } +}