@ashlr/stack-core 0.3.0 → 0.3.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/package.json +1 -1
- package/src/__tests__/_harness.ts +65 -39
- package/src/__tests__/apply-placeholder-history.test.ts +55 -0
- package/src/__tests__/apply-placeholder-operator-checklist.test.ts +28 -0
- package/src/__tests__/apply-terminology-examples.test.ts +46 -0
- package/src/__tests__/config.test.ts +36 -0
- package/src/__tests__/credential-staging.test.ts +7 -6
- package/src/__tests__/deps.test.ts +35 -0
- package/src/__tests__/detect-source.test.ts +97 -0
- package/src/__tests__/fixtures/stack-manifest.v1.json +3 -3
- package/src/__tests__/phantom-process.test.ts +21 -3
- package/src/__tests__/phantom-wire-failing-subprocess.test.ts +126 -0
- package/src/__tests__/phantom-wire-repeated-placeholders.test.ts +84 -0
- package/src/__tests__/phantom-wire-unicode-combining.test.ts +100 -0
- package/src/__tests__/phantom-wire.test.ts +6329 -363
- package/src/__tests__/pipeline-integration.test.ts +26 -2
- package/src/__tests__/recommend-apply-e2e.test.ts +2 -2
- package/src/__tests__/secret-redaction.test.ts +13 -3
- package/src/ai/phantom-wire-adjacent-different-providers.test.ts +78 -0
- package/src/ai/phantom-wire-adjacent-known-placeholders.test.ts +266 -0
- package/src/ai/phantom-wire-args.test.ts +63 -0
- package/src/ai/phantom-wire-blank-lines.test.ts +174 -0
- package/src/ai/phantom-wire-carriage-return.test.ts +125 -0
- package/src/ai/phantom-wire-case-changed-placeholder.test.ts +209 -0
- package/src/ai/phantom-wire-closing-brace.test.ts +16 -0
- package/src/ai/phantom-wire-crlf.test.ts +48 -0
- package/src/ai/phantom-wire-dollar-amp.test.ts +128 -0
- package/src/ai/phantom-wire-double-quote.test.ts +135 -0
- package/src/ai/phantom-wire-empty-replacement.test.ts +122 -0
- package/src/ai/phantom-wire-empty-stderr.test.ts +145 -0
- package/src/ai/phantom-wire-empty-template.test.ts +81 -0
- package/src/ai/phantom-wire-empty-values.test.ts +129 -0
- package/src/ai/phantom-wire-errors.test.ts +296 -0
- package/src/ai/phantom-wire-known-placeholder-adjacent-punctuation.test.ts +188 -0
- package/src/ai/phantom-wire-known-placeholder-at-end.test.ts +160 -0
- package/src/ai/phantom-wire-known-placeholder.test.ts +170 -0
- package/src/ai/phantom-wire-malformed-json.test.ts +227 -0
- package/src/ai/phantom-wire-missing.test.ts +123 -0
- package/src/ai/phantom-wire-mixed-placeholders.test.ts +183 -0
- package/src/ai/phantom-wire-nested-placeholder.test.ts +149 -0
- package/src/ai/phantom-wire-newline.test.ts +136 -0
- package/src/ai/phantom-wire-order.test.ts +173 -0
- package/src/ai/phantom-wire-placeholder-two-lines.test.ts +196 -0
- package/src/ai/phantom-wire-repeated-known-placeholder.test.ts +241 -0
- package/src/ai/phantom-wire-repeated-unknown-placeholder.test.ts +55 -0
- package/src/ai/phantom-wire-replacement-dollar.test.ts +170 -0
- package/src/ai/phantom-wire-single-quote.test.ts +22 -0
- package/src/ai/phantom-wire-tab.test.ts +42 -0
- package/src/ai/phantom-wire-unknown-placeholder.test.ts +189 -0
- package/src/ai/phantom-wire-vault-name-case.test.ts +46 -0
- package/src/ai/phantom-wire.test.ts +144 -0
- package/src/ai/phantom-wire.ts +11 -4
- package/src/phantom.ts +10 -1
- package/src/stack-manifest.ts +3 -3
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
> Shared library behind the [Ashlr Stack](https://stack.ashlr.ai) CLI and MCP server. Provider adapters, `.stack.toml` config, Phantom integration, source/env detection.
|
|
4
4
|
|
|
5
|
-
This
|
|
5
|
+
This package shares source between the [`@ashlr/stack`](https://www.npmjs.com/package/@ashlr/stack) CLI and [`ashlr-stack-mcp`](https://www.npmjs.com/package/ashlr-stack-mcp) server. Its exports point at TypeScript source. The examples below describe workspace use; installing the package does not by itself establish Node-only execution support.
|
|
6
6
|
|
|
7
7
|
## Use in this workspace
|
|
8
8
|
|
|
@@ -13,8 +13,8 @@ bun install
|
|
|
13
13
|
```
|
|
14
14
|
|
|
15
15
|
Use the selected CLI/MCP artifact and its documented runtime when installing
|
|
16
|
-
those products.
|
|
17
|
-
|
|
16
|
+
those products. Verify installed versions, package contents and runtime
|
|
17
|
+
requirements separately.
|
|
18
18
|
|
|
19
19
|
## Public API
|
|
20
20
|
|
package/package.json
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { chmodSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { __resetPhantomCache } from "../phantom.ts";
|
|
@@ -53,19 +53,60 @@ export function setupFakePhantom(
|
|
|
53
53
|
writeFileSync(vaultPath, JSON.stringify(initial));
|
|
54
54
|
writeFileSync(logPath, "");
|
|
55
55
|
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
56
|
+
// Each fake-phantom call used to spawn python3 twice; interpreter startup
|
|
57
|
+
// dominated the suite. One tiny Bun helper (same runtime as the tests) does
|
|
58
|
+
// the JSON work instead. ALL args ($1..$N) may contain user-shaped values
|
|
59
|
+
// with quotes, newlines or backslashes, so they reach the helper only through
|
|
60
|
+
// argv and are never interpolated into source.
|
|
61
|
+
const helperPath = join(dir, "helper.mjs");
|
|
62
|
+
writeFileSync(
|
|
63
|
+
helperPath,
|
|
64
|
+
`import { appendFileSync, readFileSync, writeFileSync } from "node:fs";
|
|
65
|
+
const [op, vault, ...rest] = process.argv.slice(2);
|
|
66
|
+
const load = () => JSON.parse(readFileSync(vault, "utf8"));
|
|
67
|
+
if (op === "log") {
|
|
68
|
+
appendFileSync(vault, JSON.stringify({ cmd: rest[0] ?? "", args: rest.slice(1) }) + "\\n");
|
|
69
|
+
} else if (op === "add") {
|
|
70
|
+
const v = load();
|
|
71
|
+
if (rest[0] in v) {
|
|
72
|
+
process.stderr.write("Error: Secret '" + rest[0] + "' is already protected. phantom add creates new names only and refuses replacement before reading a value.\\n");
|
|
73
|
+
process.exit(1);
|
|
74
|
+
}
|
|
75
|
+
const value = readFileSync(0, "utf8").split("\\n")[0].replace(/\\r+$/, "");
|
|
76
|
+
if (!value) {
|
|
77
|
+
process.stderr.write("Error: Received empty value on stdin\\n");
|
|
78
|
+
process.exit(1);
|
|
79
|
+
}
|
|
80
|
+
v[rest[0]] = value;
|
|
81
|
+
writeFileSync(vault, JSON.stringify(v));
|
|
82
|
+
} else if (op === "remove") {
|
|
83
|
+
const v = load();
|
|
84
|
+
delete v[rest[0]];
|
|
85
|
+
writeFileSync(vault, JSON.stringify(v));
|
|
86
|
+
} else if (op === "list") {
|
|
87
|
+
const v = load();
|
|
88
|
+
const names = Object.keys(v);
|
|
89
|
+
if (rest.includes("--json")) {
|
|
90
|
+
console.log(JSON.stringify(names.map((name) => ({ name, detected_service: null })), null, 2));
|
|
91
|
+
} else {
|
|
92
|
+
console.log("-> " + names.length + " secret(s) in vault (encrypted-file):\\n");
|
|
93
|
+
for (const k of names) console.log(" - " + k);
|
|
94
|
+
console.log("\\nnote Values are never displayed. Use phantom add/remove to manage.");
|
|
95
|
+
}
|
|
96
|
+
} else if (op === "reveal") {
|
|
97
|
+
console.log(load()[rest[0]] ?? "");
|
|
98
|
+
}
|
|
99
|
+
`,
|
|
100
|
+
);
|
|
61
101
|
const script = `#!/usr/bin/env bash
|
|
62
102
|
set -e
|
|
63
103
|
VAULT=${JSON.stringify(vaultPath)}
|
|
64
104
|
LOG=${JSON.stringify(logPath)}
|
|
105
|
+
H() { ${JSON.stringify(process.execPath)} ${JSON.stringify(helperPath)} "$@"; }
|
|
65
106
|
LEGACY_HEADLESS_REVEAL=${options.legacyHeadlessReveal ? 1 : 0}
|
|
66
107
|
|
|
67
108
|
# Log the invocation as a single JSON line (args passed via argv, never interpolated).
|
|
68
|
-
|
|
109
|
+
H log "$LOG" "$@" 2>/dev/null || true
|
|
69
110
|
|
|
70
111
|
case "\${1:-}" in
|
|
71
112
|
--version)
|
|
@@ -90,18 +131,7 @@ case "\${1:-}" in
|
|
|
90
131
|
echo "Error: Positional secret values are disabled because command-line arguments can be exposed by process inspection. Omit the value for a hidden terminal prompt, or use --stdin." >&2
|
|
91
132
|
exit 1
|
|
92
133
|
fi
|
|
93
|
-
|
|
94
|
-
with open(sys.argv[1]) as f: v = json.load(f)
|
|
95
|
-
if sys.argv[2] in v:
|
|
96
|
-
sys.stderr.write("Error: Secret %r is already protected. phantom add creates new names only and refuses replacement before reading a value.\\n" % sys.argv[2])
|
|
97
|
-
sys.exit(1)
|
|
98
|
-
value = sys.stdin.readline().rstrip("\\r\\n")
|
|
99
|
-
if not value:
|
|
100
|
-
sys.stderr.write("Error: Received empty value on stdin\\n")
|
|
101
|
-
sys.exit(1)
|
|
102
|
-
v[sys.argv[2]] = value
|
|
103
|
-
with open(sys.argv[1], "w") as f: json.dump(v, f)
|
|
104
|
-
' "$VAULT" "$2"
|
|
134
|
+
H add "$VAULT" "$2"
|
|
105
135
|
;;
|
|
106
136
|
remove)
|
|
107
137
|
# Phantom Secrets >= 0.7.3: typed confirmation in an attached terminal.
|
|
@@ -109,23 +139,11 @@ with open(sys.argv[1], "w") as f: json.dump(v, f)
|
|
|
109
139
|
echo "Error: phantom remove requires attached stdin, stdout, and stderr terminals and cannot run headlessly." >&2
|
|
110
140
|
exit 1
|
|
111
141
|
fi
|
|
112
|
-
|
|
113
|
-
with open(sys.argv[1]) as f: v = json.load(f)
|
|
114
|
-
v.pop(sys.argv[2], None)
|
|
115
|
-
with open(sys.argv[1], "w") as f: json.dump(v, f)
|
|
116
|
-
' "$VAULT" "$2"
|
|
142
|
+
H remove "$VAULT" "$2"
|
|
117
143
|
;;
|
|
118
144
|
list)
|
|
119
145
|
# Real Phantom: \`list --json\` prints [{"name": ...}, ...] (names only).
|
|
120
|
-
|
|
121
|
-
with open(sys.argv[1]) as f: v = json.load(f)
|
|
122
|
-
if "--json" in sys.argv[2:]:
|
|
123
|
-
print(json.dumps([{"name": k, "detected_service": None} for k in v], indent=2))
|
|
124
|
-
else:
|
|
125
|
-
print("-> %d secret(s) in vault (encrypted-file):\\n" % len(v))
|
|
126
|
-
for k in v: print(" - " + k)
|
|
127
|
-
print("\\nnote Values are never displayed. Use phantom add/remove to manage.")
|
|
128
|
-
' "$VAULT" "\${@:2}"
|
|
146
|
+
H list "$VAULT" "\${@:2}"
|
|
129
147
|
;;
|
|
130
148
|
reveal)
|
|
131
149
|
# Phantom Secrets >= 0.7.3: plaintext only into an attached terminal.
|
|
@@ -133,10 +151,7 @@ else:
|
|
|
133
151
|
echo "Error: Refusing to reveal a secret without attached stdin and stderr terminals" >&2
|
|
134
152
|
exit 1
|
|
135
153
|
fi
|
|
136
|
-
|
|
137
|
-
with open(sys.argv[1]) as f: v = json.load(f)
|
|
138
|
-
print(v.get(sys.argv[2], ""))
|
|
139
|
-
' "$VAULT" "$2"
|
|
154
|
+
H reveal "$VAULT" "$2"
|
|
140
155
|
;;
|
|
141
156
|
init)
|
|
142
157
|
# no-op for the fake — the vault file already exists
|
|
@@ -149,8 +164,19 @@ print(v.get(sys.argv[2], ""))
|
|
|
149
164
|
esac
|
|
150
165
|
`;
|
|
151
166
|
const binPath = join(dir, "phantom");
|
|
152
|
-
|
|
153
|
-
|
|
167
|
+
// Sandboxed runners and macOS Gatekeeper's provenance policy refuse to
|
|
168
|
+
// exec an extensionless file written by a non-platform process, while a
|
|
169
|
+
// `*.sh` target passes the check. Write the shim as `phantom.sh` and link
|
|
170
|
+
// `phantom` at it; fall back to the direct file where symlinks fail.
|
|
171
|
+
const realPath = join(dir, "phantom.sh");
|
|
172
|
+
writeFileSync(realPath, script);
|
|
173
|
+
chmodSync(realPath, 0o755);
|
|
174
|
+
try {
|
|
175
|
+
symlinkSync(realPath, binPath);
|
|
176
|
+
} catch {
|
|
177
|
+
writeFileSync(binPath, script);
|
|
178
|
+
chmodSync(binPath, 0o755);
|
|
179
|
+
}
|
|
154
180
|
|
|
155
181
|
const previousPath = process.env.PATH ?? "";
|
|
156
182
|
process.env.PATH = `${dir}:${previousPath}`;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
|
|
4
|
+
const docUrl = new URL("../../../../docs/apply-placeholder-history.md", import.meta.url);
|
|
5
|
+
const history = readFileSync(docUrl, "utf8");
|
|
6
|
+
const location = readFileSync(
|
|
7
|
+
new URL("../../../../docs/apply-history-location.md", import.meta.url),
|
|
8
|
+
"utf8",
|
|
9
|
+
);
|
|
10
|
+
|
|
11
|
+
test.each([
|
|
12
|
+
[
|
|
13
|
+
"../CHANGELOG.md",
|
|
14
|
+
"010--2026-04-19-superseded-by-011",
|
|
15
|
+
"## 0.1.0 — 2026-04-19 (superseded by 0.1.1)",
|
|
16
|
+
"- **`stack apply [recipe-id]`**",
|
|
17
|
+
],
|
|
18
|
+
[
|
|
19
|
+
"../packages/site/src/data/CHANGELOG.md",
|
|
20
|
+
"010--2026-04-19-superseded-by-011",
|
|
21
|
+
"## 0.1.0 — 2026-04-19 (superseded by 0.1.1)",
|
|
22
|
+
"- **`stack apply [recipe-id]`**",
|
|
23
|
+
],
|
|
24
|
+
[
|
|
25
|
+
"../CHANGELOG.md",
|
|
26
|
+
"021-2026-10-16",
|
|
27
|
+
"## 0.2.1 (2026-10-16)",
|
|
28
|
+
"- **`stack apply` stops attempting to write placeholder secrets.**",
|
|
29
|
+
],
|
|
30
|
+
[
|
|
31
|
+
"../packages/site/src/data/CHANGELOG.md",
|
|
32
|
+
"021-2026-10-16",
|
|
33
|
+
"## 0.2.1 (2026-10-16)",
|
|
34
|
+
"- **`stack apply` stops attempting to write placeholder secrets.**",
|
|
35
|
+
],
|
|
36
|
+
])("history links to the relevant entry in %s#%s", (path, anchor, heading, entryPrefix) => {
|
|
37
|
+
expect(history).toContain(`](${path}#${anchor})`);
|
|
38
|
+
expect(location).toContain(`](${path}#${anchor})`);
|
|
39
|
+
const changelog = readFileSync(new URL(path, docUrl), "utf8");
|
|
40
|
+
const release = changelog.split(`${heading}\n`)[1]?.split("\n## ")[0];
|
|
41
|
+
expect(release).toBeDefined();
|
|
42
|
+
const entry = release?.split("\n").find((line) => line.startsWith(entryPrefix));
|
|
43
|
+
expect(entry).toContain("TODO_STACK_APPLY");
|
|
44
|
+
expect(entry).toContain("failed");
|
|
45
|
+
expect(entry).toMatch(
|
|
46
|
+
/(?:does not write|never writes) to the vault or fills? (?:in )?missing values/,
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("location distinguishes failed historical attempts from the fix", () => {
|
|
51
|
+
expect(location).toContain("Phantom rejected those attempts");
|
|
52
|
+
expect(location).toContain("The 0.2.1 fix removed the attempts");
|
|
53
|
+
expect(location).toContain("provider setup may separately store real");
|
|
54
|
+
expect(location).toContain("](../CHANGELOG.md#022-2026-10-25)");
|
|
55
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { expect, it } from "bun:test";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
|
|
4
|
+
const checklist = readFileSync(
|
|
5
|
+
new URL("../../../../docs/apply-placeholder-operator-checklist.md", import.meta.url),
|
|
6
|
+
"utf8",
|
|
7
|
+
);
|
|
8
|
+
const changelog = readFileSync(new URL("../../../../CHANGELOG.md", import.meta.url), "utf8");
|
|
9
|
+
|
|
10
|
+
it("identifies the historical marker as a failed attempt, never an operator value", () => {
|
|
11
|
+
const marker = "TODO_STACK_APPLY";
|
|
12
|
+
expect(changelog).toContain(marker);
|
|
13
|
+
expect(checklist).toContain(marker);
|
|
14
|
+
expect(checklist).toContain("attempts failed");
|
|
15
|
+
expect(checklist).toContain("Do not use the marker as a secret value");
|
|
16
|
+
for (const block of checklist.matchAll(/```[^\n]*\n([\s\S]*?)```/g)) {
|
|
17
|
+
expect(block[1]).not.toContain(marker);
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("directs operators to enter real values and treats incomplete inventory as unknown", () => {
|
|
22
|
+
expect(checklist).toContain("`phantom add <NAME>`");
|
|
23
|
+
expect(checklist).toContain("`phantom add OPENAI_API_KEY`");
|
|
24
|
+
expect(checklist).toContain("enter the real value when prompted");
|
|
25
|
+
expect(checklist).toContain("do not put the value in command");
|
|
26
|
+
expect(checklist).toContain("`skipped`, not missing");
|
|
27
|
+
expect(checklist).toContain("`--noWire` skips both name checks and webhook");
|
|
28
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { afterEach, beforeEach, expect, it, setDefaultTimeout } from "bun:test";
|
|
2
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { wirePhantomForRecipe } from "../ai/phantom-wire.ts";
|
|
4
|
+
import type { Recipe } from "../ai/recipe.ts";
|
|
5
|
+
import { type Harness, setupFakePhantom } from "./_harness.ts";
|
|
6
|
+
|
|
7
|
+
setDefaultTimeout(30_000);
|
|
8
|
+
|
|
9
|
+
const doc = readFileSync(
|
|
10
|
+
new URL("../../../../docs/apply-terminology-examples.md", import.meta.url),
|
|
11
|
+
"utf-8",
|
|
12
|
+
);
|
|
13
|
+
const examples = [...doc.matchAll(/```json\n([^\n]+)\n```/g)].map((match) => JSON.parse(match[1]));
|
|
14
|
+
const recipe: Recipe = {
|
|
15
|
+
id: "example",
|
|
16
|
+
query: "example",
|
|
17
|
+
createdAt: "2026-04-18T00:00:00.000Z",
|
|
18
|
+
providers: [{ name: "openai" }],
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
let harness: Harness;
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
harness = setupFakePhantom({ OPENAI_API_KEY: "test-value" });
|
|
24
|
+
});
|
|
25
|
+
afterEach(() => harness.cleanup());
|
|
26
|
+
|
|
27
|
+
it("documents the readable inventory examples without writing placeholders", async () => {
|
|
28
|
+
expect(examples).toHaveLength(3);
|
|
29
|
+
expect(await wirePhantomForRecipe(recipe, { cwd: harness.dir })).toEqual(examples[0]);
|
|
30
|
+
writeFileSync(harness.vaultPath, "{}");
|
|
31
|
+
expect(await wirePhantomForRecipe(recipe, { cwd: harness.dir })).toEqual(examples[1]);
|
|
32
|
+
expect(harness.callsTo("add")).toEqual([]);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("documents an unreadable inventory alongside the generated Stripe stub", async () => {
|
|
36
|
+
writeFileSync(harness.vaultPath, "not json");
|
|
37
|
+
const result = await wirePhantomForRecipe(
|
|
38
|
+
{ ...recipe, providers: [{ name: "stripe" }] },
|
|
39
|
+
{ cwd: harness.dir },
|
|
40
|
+
);
|
|
41
|
+
expect({
|
|
42
|
+
...result,
|
|
43
|
+
webhooks: result.webhooks.map((path) => path.replace(harness.dir, "<project>")),
|
|
44
|
+
}).toEqual(examples[2]);
|
|
45
|
+
expect(harness.callsTo("add")).toEqual([]);
|
|
46
|
+
});
|
|
@@ -68,6 +68,42 @@ describe("config", () => {
|
|
|
68
68
|
expect(roundTripped.services.supabase).toEqual(original.services.supabase);
|
|
69
69
|
});
|
|
70
70
|
|
|
71
|
+
test("a full configuration survives serialize → parse with every value preserved", async () => {
|
|
72
|
+
const cwd = freshCwd();
|
|
73
|
+
const original = emptyConfig("saas");
|
|
74
|
+
original.services.supabase = {
|
|
75
|
+
provider: "supabase",
|
|
76
|
+
resource_id: "abcd1234",
|
|
77
|
+
region: "us-east-1",
|
|
78
|
+
secrets: ["SUPABASE_URL", "SUPABASE_ANON_KEY"],
|
|
79
|
+
mcp: "supabase",
|
|
80
|
+
meta: { plan: "free", replicas: 2, nested: { ok: true, tags: ["a", "b"] } },
|
|
81
|
+
created_at: "2026-04-17T05:00:00Z",
|
|
82
|
+
created_by: "test",
|
|
83
|
+
};
|
|
84
|
+
original.services.sentry = {
|
|
85
|
+
provider: "sentry",
|
|
86
|
+
secrets: [],
|
|
87
|
+
created_at: "2026-04-18T05:00:00Z",
|
|
88
|
+
};
|
|
89
|
+
original.environments = [
|
|
90
|
+
{ name: "dev", default: true },
|
|
91
|
+
{ name: "prod", overrides: { SUPABASE_URL: "SUPABASE_URL_PROD" } },
|
|
92
|
+
];
|
|
93
|
+
|
|
94
|
+
await writeConfig(original, cwd);
|
|
95
|
+
const parsed = await readConfig(cwd);
|
|
96
|
+
expect(parsed).toEqual(original);
|
|
97
|
+
|
|
98
|
+
// Re-serializing the parsed config is stable: the files do not drift.
|
|
99
|
+
const shapeBefore = readFileSync(join(cwd, CONFIG_FILENAME), "utf-8");
|
|
100
|
+
const localBefore = readFileSync(join(cwd, LOCAL_FILENAME), "utf-8");
|
|
101
|
+
await writeConfig(parsed, cwd);
|
|
102
|
+
expect(readFileSync(join(cwd, CONFIG_FILENAME), "utf-8")).toBe(shapeBefore);
|
|
103
|
+
expect(readFileSync(join(cwd, LOCAL_FILENAME), "utf-8")).toBe(localBefore);
|
|
104
|
+
expect(await readConfig(cwd)).toEqual(original);
|
|
105
|
+
});
|
|
106
|
+
|
|
71
107
|
test("readConfig throws ConfigNotFoundError when file is missing", async () => {
|
|
72
108
|
const cwd = freshCwd();
|
|
73
109
|
await expect(readConfig(cwd)).rejects.toThrow(/No .stack.toml at/);
|
|
@@ -242,12 +242,13 @@ describe("operation-owned credential persistence", () => {
|
|
|
242
242
|
});
|
|
243
243
|
test("reads/writes stay at captured absolute cwd despite ctx and opts mutation", async () => {
|
|
244
244
|
// Add cwd metadata to the inert producer log, never secret input values.
|
|
245
|
-
const
|
|
245
|
+
const helper = join(h.dir, "helper.mjs");
|
|
246
246
|
writeFileSync(
|
|
247
|
-
|
|
248
|
-
readFileSync(
|
|
249
|
-
.
|
|
250
|
-
.
|
|
247
|
+
helper,
|
|
248
|
+
readFileSync(helper, "utf8").replace(
|
|
249
|
+
"JSON.stringify({ cmd: rest[0]",
|
|
250
|
+
"JSON.stringify({ cwd: process.cwd(), cmd: rest[0]",
|
|
251
|
+
),
|
|
251
252
|
);
|
|
252
253
|
const opts = { providerName: "stagefixture", cwd, interactive: true };
|
|
253
254
|
const p = factory();
|
|
@@ -267,7 +268,7 @@ describe("operation-owned credential persistence", () => {
|
|
|
267
268
|
.trim()
|
|
268
269
|
.split("\n")
|
|
269
270
|
.map((line) => JSON.parse(line));
|
|
270
|
-
//
|
|
271
|
+
// process.cwd() resolves filesystem aliases such as macOS /var.
|
|
271
272
|
for (const row of rows.filter((r) => ["status", "reveal", "add"].includes(r.cmd)))
|
|
272
273
|
expect(row.cwd).toBe(realpathSync(cwd));
|
|
273
274
|
expect((await readConfig(cwd)).services.stagefixture).toBeDefined();
|
|
@@ -2,9 +2,22 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { mkdtempSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
+
import { buildDepsReport } from "../../../cli/src/commands/deps.ts";
|
|
5
6
|
import { emptyConfig, writeConfig } from "../config.ts";
|
|
6
7
|
import { getProvider, listProviderNames } from "../providers/index.ts";
|
|
7
8
|
|
|
9
|
+
test("an empty service list resolves to an empty dependency report", async () => {
|
|
10
|
+
const config = emptyConfig();
|
|
11
|
+
expect(await buildDepsReport(config)).toEqual({
|
|
12
|
+
schema: "ashlr.stack.deps/v1",
|
|
13
|
+
project_id: config.stack.project_id,
|
|
14
|
+
template: null,
|
|
15
|
+
categories: [],
|
|
16
|
+
unresolved: [],
|
|
17
|
+
totals: { services: 0, secrets: 0, mcp: 0 },
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
|
|
8
21
|
describe("provider loading — wave 9 additions", () => {
|
|
9
22
|
test("render + firebase are in the catalog", () => {
|
|
10
23
|
const names = listProviderNames();
|
|
@@ -56,4 +69,26 @@ describe("config carrying 21+ provider entries still round-trips", () => {
|
|
|
56
69
|
const roundtrip = await readConfig(cwd);
|
|
57
70
|
expect(Object.keys(roundtrip.services).length).toBe(listProviderNames().length);
|
|
58
71
|
});
|
|
72
|
+
test("duplicate secret entries are preserved verbatim (no dedupe on round-trip)", async () => {
|
|
73
|
+
const config = emptyConfig("dupes");
|
|
74
|
+
config.services.render = {
|
|
75
|
+
provider: "render",
|
|
76
|
+
secrets: ["RENDER_API_KEY", "RENDER_API_KEY"],
|
|
77
|
+
created_at: new Date().toISOString(),
|
|
78
|
+
};
|
|
79
|
+
await writeConfig(config, cwd);
|
|
80
|
+
const { readConfig } = await import("../config.ts");
|
|
81
|
+
const roundtrip = await readConfig(cwd);
|
|
82
|
+
expect(roundtrip.services.render?.secrets).toEqual(["RENDER_API_KEY", "RENDER_API_KEY"]);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("a duplicated [services.x] table in .stack.toml is rejected, not merged", async () => {
|
|
86
|
+
const { readConfig, CONFIG_FILENAME } = await import("../config.ts");
|
|
87
|
+
const toml =
|
|
88
|
+
'[stack]\nversion = "1"\n\n' +
|
|
89
|
+
'[services.render]\nprovider = "render"\nsecrets = ["A"]\n\n' +
|
|
90
|
+
'[services.render]\nprovider = "render"\nsecrets = ["B"]\n';
|
|
91
|
+
await Bun.write(join(cwd, CONFIG_FILENAME), toml);
|
|
92
|
+
await expect(readConfig(cwd)).rejects.toThrow();
|
|
93
|
+
});
|
|
59
94
|
});
|
|
@@ -3,6 +3,7 @@ import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync
|
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { delimiter, dirname, join } from "node:path";
|
|
5
5
|
import { scanSource } from "../detect-source.ts";
|
|
6
|
+
import { SourceDirectoryNotFoundError } from "../errors.ts";
|
|
6
7
|
|
|
7
8
|
const projects: string[] = [];
|
|
8
9
|
afterEach(() => {
|
|
@@ -152,6 +153,26 @@ require (
|
|
|
152
153
|
});
|
|
153
154
|
});
|
|
154
155
|
|
|
156
|
+
test.each([
|
|
157
|
+
["dependencies", "openai"],
|
|
158
|
+
["dependencies", ["openai"]],
|
|
159
|
+
["devDependencies", "openai"],
|
|
160
|
+
["devDependencies", ["openai"]],
|
|
161
|
+
] as const)("invalid %s section %j preserves a valid sibling", async (section, invalid) => {
|
|
162
|
+
const sibling = section === "dependencies" ? "devDependencies" : "dependencies";
|
|
163
|
+
const hits = await scanSource(
|
|
164
|
+
makeProject({
|
|
165
|
+
"package.json": JSON.stringify({
|
|
166
|
+
[section]: invalid,
|
|
167
|
+
[sibling]: { openai: "^4.0.0" },
|
|
168
|
+
}),
|
|
169
|
+
}),
|
|
170
|
+
);
|
|
171
|
+
expect(hits).toEqual([
|
|
172
|
+
{ provider: "openai", confidence: "high", signals: ["package.json:openai"] },
|
|
173
|
+
]);
|
|
174
|
+
});
|
|
175
|
+
|
|
155
176
|
test.each(["{broken", "null", "{}"])(
|
|
156
177
|
"malformed or empty package %s does not hide other evidence",
|
|
157
178
|
async (raw) => {
|
|
@@ -218,6 +239,36 @@ test("deduplicates dependency evidence and upgrades confidence without losing si
|
|
|
218
239
|
]);
|
|
219
240
|
});
|
|
220
241
|
|
|
242
|
+
test("repeated scans of an unchanged project return the same unique detections", async () => {
|
|
243
|
+
const cwd = makeProject({
|
|
244
|
+
"package.json": JSON.stringify({
|
|
245
|
+
dependencies: { openai: "1", "@vercel/kv": "1" },
|
|
246
|
+
devDependencies: { openai: "1" },
|
|
247
|
+
}),
|
|
248
|
+
"requirements.txt": "openai==1\nopenai>=1\n",
|
|
249
|
+
"vercel.json": "{}",
|
|
250
|
+
".env.example": "OPENAI_API_KEY=\nOPENAI_API_KEY=\nVERCEL_TOKEN=",
|
|
251
|
+
});
|
|
252
|
+
const expected: Awaited<ReturnType<typeof scanSource>> = [
|
|
253
|
+
{
|
|
254
|
+
provider: "openai",
|
|
255
|
+
confidence: "high",
|
|
256
|
+
signals: ["package.json:openai", "requirements.txt:openai", ".env.example:OPENAI_API_KEY"],
|
|
257
|
+
},
|
|
258
|
+
{
|
|
259
|
+
provider: "vercel",
|
|
260
|
+
confidence: "high",
|
|
261
|
+
signals: ["package.json:@vercel/kv", "vercel.json", ".env.example:VERCEL_TOKEN"],
|
|
262
|
+
},
|
|
263
|
+
];
|
|
264
|
+
|
|
265
|
+
const first = await scanSource(cwd);
|
|
266
|
+
const second = await scanSource(cwd);
|
|
267
|
+
|
|
268
|
+
expect(first).toEqual(expected);
|
|
269
|
+
expect(second).toEqual(expected);
|
|
270
|
+
});
|
|
271
|
+
|
|
221
272
|
test("scans a project directory containing spaces and reports relative file paths", async () => {
|
|
222
273
|
const root = makeProject({
|
|
223
274
|
"project with spaces/package.json": JSON.stringify({ dependencies: { openai: "1" } }),
|
|
@@ -279,6 +330,32 @@ test("empty project has no detections", async () => {
|
|
|
279
330
|
expect(await scanSource(makeProject({}))).toEqual([]);
|
|
280
331
|
});
|
|
281
332
|
|
|
333
|
+
test("unrelated text file with provider-like content has no detections", async () => {
|
|
334
|
+
const cwd = makeProject({
|
|
335
|
+
"notes.txt": 'openai\nOPENAI_API_KEY=\n{"dependencies":{"@supabase/supabase-js":"1"}}',
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
expect(await scanSource(cwd)).toEqual([]);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
test("an empty scan root ignores provider markers in its parent and stays empty", async () => {
|
|
342
|
+
const parent = makeProject({
|
|
343
|
+
"package.json": JSON.stringify({ dependencies: { openai: "1" } }),
|
|
344
|
+
"vercel.json": "{}",
|
|
345
|
+
".env.example": "ANTHROPIC_API_KEY=",
|
|
346
|
+
});
|
|
347
|
+
const root = join(parent, "empty project");
|
|
348
|
+
mkdirSync(root);
|
|
349
|
+
|
|
350
|
+
expect((await scanSource(parent)).map((hit) => hit.provider)).toEqual([
|
|
351
|
+
"openai",
|
|
352
|
+
"vercel",
|
|
353
|
+
"anthropic",
|
|
354
|
+
]);
|
|
355
|
+
expect(await scanSource(root)).toEqual([]);
|
|
356
|
+
expect(readdirSync(root)).toEqual([]);
|
|
357
|
+
});
|
|
358
|
+
|
|
282
359
|
test.each([["--json"], ["--yes"]])(
|
|
283
360
|
"scan %s rejects a nonexistent project without creating files",
|
|
284
361
|
async (flag) => {
|
|
@@ -324,3 +401,23 @@ test.each([
|
|
|
324
401
|
] as const)("empty dependency manifest %s produces no detections", async (file, content) => {
|
|
325
402
|
expect(await scanSource(makeProject({ [file]: content }))).toEqual([]);
|
|
326
403
|
});
|
|
404
|
+
|
|
405
|
+
describe("source scanner — missing project directory", () => {
|
|
406
|
+
test("fails clearly and does not create the directory", async () => {
|
|
407
|
+
const parent = makeProject({});
|
|
408
|
+
const missing = join(parent, "no-such-project");
|
|
409
|
+
const err = await scanSource(missing).catch((e: unknown) => e);
|
|
410
|
+
expect(err).toBeInstanceOf(SourceDirectoryNotFoundError);
|
|
411
|
+
expect(err).toMatchObject({ code: "SOURCE_DIRECTORY_NOT_FOUND" });
|
|
412
|
+
expect((err as Error).message).toContain(missing);
|
|
413
|
+
expect((err as Error).message).toBe(`Source directory not found: ${missing}`);
|
|
414
|
+
expect(existsSync(missing)).toBe(false);
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
test("rejects a path that exists but is not a directory", async () => {
|
|
418
|
+
const cwd = makeProject({ "package.json": "{}" });
|
|
419
|
+
const file = join(cwd, "package.json");
|
|
420
|
+
await expect(scanSource(file)).rejects.toThrow(SourceDirectoryNotFoundError);
|
|
421
|
+
expect(existsSync(file)).toBe(true);
|
|
422
|
+
});
|
|
423
|
+
});
|
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
"schema_version": 1,
|
|
3
3
|
"kind": "ashlr-stack-manifest",
|
|
4
4
|
"components": [
|
|
5
|
-
{ "id": "core", "name": "@ashlr/stack-core", "version": "0.3.
|
|
6
|
-
{ "id": "cli", "name": "@ashlr/stack", "version": "0.3.
|
|
7
|
-
{ "id": "mcp", "name": "ashlr-stack-mcp", "version": "0.3.
|
|
5
|
+
{ "id": "core", "name": "@ashlr/stack-core", "version": "0.3.6", "distribution": "npm" },
|
|
6
|
+
{ "id": "cli", "name": "@ashlr/stack", "version": "0.3.6", "distribution": "npm" },
|
|
7
|
+
{ "id": "mcp", "name": "ashlr-stack-mcp", "version": "0.3.6", "distribution": "npm" },
|
|
8
8
|
{ "id": "plugin", "name": "stack", "version": "0.1.0", "distribution": "claude-code-plugin" }
|
|
9
9
|
]
|
|
10
10
|
}
|
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
2
|
import { spawnSync } from "node:child_process";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
chmodSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
mkdtempSync,
|
|
7
|
+
realpathSync,
|
|
8
|
+
rmSync,
|
|
9
|
+
symlinkSync,
|
|
10
|
+
writeFileSync,
|
|
11
|
+
} from "node:fs";
|
|
4
12
|
import { tmpdir } from "node:os";
|
|
5
13
|
import { delimiter, join, resolve } from "node:path";
|
|
6
14
|
import { PhantomNotInstalledError } from "../errors.ts";
|
|
@@ -18,8 +26,18 @@ afterEach(() => {
|
|
|
18
26
|
const name = process.platform === "win32" ? "phantom.exe" : "phantom";
|
|
19
27
|
function binary(dir: string, script = "#!/bin/sh\nexit 0\n"): string {
|
|
20
28
|
const path = join(dir, name);
|
|
21
|
-
|
|
22
|
-
|
|
29
|
+
// Sandboxed runners and macOS Gatekeeper's provenance policy refuse to
|
|
30
|
+
// exec an extensionless file written by a non-platform process, while a
|
|
31
|
+
// `*.sh` target passes the check: point `phantom` at a `phantom.sh` shim.
|
|
32
|
+
const real = join(dir, `${name}.sh`);
|
|
33
|
+
writeFileSync(real, script);
|
|
34
|
+
chmodSync(real, 0o755);
|
|
35
|
+
try {
|
|
36
|
+
symlinkSync(real, path);
|
|
37
|
+
} catch {
|
|
38
|
+
writeFileSync(path, script);
|
|
39
|
+
chmodSync(path, 0o755);
|
|
40
|
+
}
|
|
23
41
|
return path;
|
|
24
42
|
}
|
|
25
43
|
|