@stacksjs/buddy 0.70.163 → 0.70.164

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.
Files changed (78) hide show
  1. package/README.md +1 -1
  2. package/dist/cli.js +1 -0
  3. package/dist/commands/ai-context.js +1 -1
  4. package/dist/commands/create.js +1 -1
  5. package/dist/commands/deploy.d.ts +11 -1
  6. package/dist/commands/deploy.js +22 -4
  7. package/dist/commands/dev.js +44 -14
  8. package/dist/commands/docs/buddy-commands.d.ts +8 -0
  9. package/dist/commands/docs/buddy-commands.js +116 -0
  10. package/dist/commands/docs/buddy-commands.test.d.ts +1 -0
  11. package/dist/commands/docs/buddy-commands.test.js +38 -0
  12. package/dist/commands/docs/generated-artifacts.d.ts +3 -0
  13. package/dist/commands/docs/generated-artifacts.js +69 -0
  14. package/dist/commands/docs/generated-artifacts.test.d.ts +1 -0
  15. package/dist/commands/docs/generated-artifacts.test.js +19 -0
  16. package/dist/commands/docs/links.d.ts +18 -0
  17. package/dist/commands/docs/links.js +83 -0
  18. package/dist/commands/docs/links.test.d.ts +1 -0
  19. package/dist/commands/docs/links.test.js +73 -0
  20. package/dist/commands/docs.d.ts +7 -0
  21. package/dist/commands/docs.js +24 -0
  22. package/dist/commands/doctor.js +8 -0
  23. package/dist/commands/env.d.ts +14 -0
  24. package/dist/commands/env.js +73 -7
  25. package/dist/commands/generate.js +1 -1
  26. package/dist/commands/index.d.ts +2 -0
  27. package/dist/commands/index.js +2 -0
  28. package/dist/commands/mail.js +1 -1
  29. package/dist/commands/migrate.js +4 -4
  30. package/dist/commands/protocol/craft-evidence.d.ts +76 -0
  31. package/dist/commands/protocol/craft-evidence.js +98 -0
  32. package/dist/commands/protocol/craft-evidence.test.d.ts +1 -0
  33. package/dist/commands/protocol/craft-evidence.test.js +20 -0
  34. package/dist/commands/protocol/desktop-lifecycle-report.d.ts +16 -0
  35. package/dist/commands/protocol/desktop-lifecycle-report.js +57 -0
  36. package/dist/commands/protocol/desktop-lifecycle-report.test.d.ts +1 -0
  37. package/dist/commands/protocol/desktop-lifecycle-report.test.js +32 -0
  38. package/dist/commands/protocol/desktop-support.d.ts +1 -0
  39. package/dist/commands/protocol/desktop-support.js +32 -0
  40. package/dist/commands/protocol/desktop-workflow.test.d.ts +1 -0
  41. package/dist/commands/protocol/desktop-workflow.test.js +33 -0
  42. package/dist/commands/protocol/driver-registry.d.ts +1 -0
  43. package/dist/commands/protocol/driver-registry.js +71 -0
  44. package/dist/commands/protocol/pantry-evidence.d.ts +67 -0
  45. package/dist/commands/protocol/pantry-evidence.js +144 -0
  46. package/dist/commands/protocol/pantry-evidence.test.d.ts +1 -0
  47. package/dist/commands/protocol/pantry-evidence.test.js +29 -0
  48. package/dist/commands/protocol/release-manifest.d.ts +30 -0
  49. package/dist/commands/protocol/release-manifest.js +102 -0
  50. package/dist/commands/protocol/release-manifest.test.d.ts +1 -0
  51. package/dist/commands/protocol/release-manifest.test.js +34 -0
  52. package/dist/commands/protocol/run-conformance.d.ts +21 -0
  53. package/dist/commands/protocol/run-conformance.js +358 -0
  54. package/dist/commands/protocol/run-conformance.test.d.ts +1 -0
  55. package/dist/commands/protocol/run-conformance.test.js +52 -0
  56. package/dist/commands/protocol/run-driver-contracts.d.ts +7 -0
  57. package/dist/commands/protocol/run-driver-contracts.js +94 -0
  58. package/dist/commands/protocol/run-driver-contracts.test.d.ts +1 -0
  59. package/dist/commands/protocol/run-driver-contracts.test.js +18 -0
  60. package/dist/commands/protocol/source-manifest.d.ts +25 -0
  61. package/dist/commands/protocol/source-manifest.js +113 -0
  62. package/dist/commands/protocol/sync-suite.d.ts +8 -0
  63. package/dist/commands/protocol/sync-suite.js +137 -0
  64. package/dist/commands/protocol/sync-suite.test.d.ts +1 -0
  65. package/dist/commands/protocol/sync-suite.test.js +44 -0
  66. package/dist/commands/protocol.d.ts +8 -0
  67. package/dist/commands/protocol.js +60 -0
  68. package/dist/commands/publish.js +149 -0
  69. package/dist/commands/run-tool.d.ts +7 -0
  70. package/dist/commands/run-tool.js +9 -0
  71. package/dist/commands/seed.js +15 -40
  72. package/dist/commands/serve.d.ts +18 -5
  73. package/dist/commands/serve.js +21 -2
  74. package/dist/commands/setup-ai.d.ts +39 -0
  75. package/dist/commands/setup-ai.js +121 -0
  76. package/dist/commands/setup.js +22 -0
  77. package/dist/lazy-commands.js +27 -1
  78. package/package.json +43 -42
@@ -0,0 +1,73 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { extractLinks, isSkippableLink, resolveCandidates } from "./links";
3
+ describe("docs link checker (stacksjs/stacks#2056)", () => {
4
+ describe("isSkippableLink", () => {
5
+ it("skips external, mail/tel, anchors, protocol-relative, and templated links", () => {
6
+ for (const target of ["https://x.com", "http://x.com", "mailto:a@b.c", "tel:123", "#section", "//cdn.example.com/x", "{{ url }}", "data:image/png;base64,AAAA", ""])
7
+ expect(isSkippableLink(target)).toBe(!0);
8
+ });
9
+ it("does not skip internal relative or absolute links", () => {
10
+ for (const target of ["./foo.md", "../bar/baz.md", "/guide/intro", "sibling.md"])
11
+ expect(isSkippableLink(target)).toBe(!1);
12
+ });
13
+ });
14
+ describe("extractLinks", () => {
15
+ it("extracts inline links with 1-based line numbers", () => {
16
+ expect(extractLinks(`intro
17
+
18
+ see [a](./a.md) and [b](../b.md)
19
+ `)).toEqual([
20
+ { target: "./a.md", line: 3 },
21
+ { target: "../b.md", line: 3 }
22
+ ]);
23
+ });
24
+ it("strips an optional link title", () => {
25
+ expect(extractLinks('[x](/guide/x "The X page")')).toEqual([{ target: "/guide/x", line: 1 }]);
26
+ });
27
+ it("ignores links inside fenced code blocks", () => {
28
+ expect(extractLinks("```md\n[x](./nope.md)\n```\n[y](./yes.md)\n")).toEqual([{ target: "./yes.md", line: 4 }]);
29
+ });
30
+ it("ignores links inside inline code spans", () => {
31
+ expect(extractLinks("use `[x](./nope.md)` but link [y](./yes.md)")).toEqual([{ target: "./yes.md", line: 1 }]);
32
+ });
33
+ it("ignores links inside HTML comments while preserving line numbers", () => {
34
+ expect(extractLinks(`line1
35
+ <!-- ![x](./commented.png) -->
36
+ real [y](./y.md)
37
+ `)).toEqual([{ target: "./y.md", line: 3 }]);
38
+ });
39
+ it("handles a multi-line HTML comment", () => {
40
+ expect(extractLinks(`<!--
41
+ [x](./a.md)
42
+ -->
43
+ [y](./b.md)
44
+ `)).toEqual([{ target: "./b.md", line: 4 }]);
45
+ });
46
+ });
47
+ describe("resolveCandidates", () => {
48
+ const docsRoot = "/docs", fileDir = "/docs/guide/components";
49
+ it("resolves a relative link against the file directory", () => {
50
+ expect(resolveCandidates("./tabs.md", fileDir, docsRoot)).toEqual(["/docs/guide/components/tabs.md"]);
51
+ });
52
+ it("resolves an absolute link against the docs root", () => {
53
+ expect(resolveCandidates("/guide/intro.md", fileDir, docsRoot)).toEqual(["/docs/guide/intro.md"]);
54
+ });
55
+ it("offers .md and index.md candidates for an extensionless (clean-URL) link", () => {
56
+ expect(resolveCandidates("../intro", fileDir, docsRoot)).toEqual([
57
+ "/docs/guide/intro",
58
+ "/docs/guide/intro.md",
59
+ "/docs/guide/intro/index.md"
60
+ ]);
61
+ });
62
+ it("offers the .md source for an .html link", () => {
63
+ const candidates = resolveCandidates("./api.html", fileDir, docsRoot);
64
+ expect(candidates).toContain("/docs/guide/components/api.md");
65
+ });
66
+ it("strips the anchor before resolving", () => {
67
+ expect(resolveCandidates("./tabs.md#usage", fileDir, docsRoot)).toEqual(["/docs/guide/components/tabs.md"]);
68
+ });
69
+ it("returns no candidates for a pure anchor", () => {
70
+ expect(resolveCandidates("#usage", fileDir, docsRoot)).toEqual([]);
71
+ });
72
+ });
73
+ });
@@ -0,0 +1,7 @@
1
+ import type { CLI } from '@stacksjs/types';
2
+ /**
3
+ * Framework-repo `buddy docs:*` commands wrapping the documentation-freshness
4
+ * tooling under `commands/docs/`: the generated buddy command reference,
5
+ * generated API artifacts (OpenAPI + types), and internal link checking.
6
+ */
7
+ export declare function docs(buddy: CLI): void;
@@ -0,0 +1,24 @@
1
+ import { run as runArtifacts } from "./docs/generated-artifacts";
2
+ import { run as runBuddyDocs } from "./docs/buddy-commands";
3
+ import { run as runLinks } from "./docs/links";
4
+ import { runTool } from "./run-tool";
5
+ export function docs(buddy) {
6
+ buddy.command("docs:buddy", "Regenerate the buddy command reference doc").action(async () => {
7
+ await runTool(runBuddyDocs, "--write");
8
+ });
9
+ buddy.command("docs:buddy:check", "Verify the buddy command reference doc is current").action(async () => {
10
+ await runTool(runBuddyDocs, "--check");
11
+ });
12
+ buddy.command("docs:artifacts", "Regenerate the generated API artifacts (OpenAPI + types)").action(async () => {
13
+ await runTool(runArtifacts, "--write");
14
+ });
15
+ buddy.command("docs:artifacts:check", "Verify the generated API artifacts are current").action(async () => {
16
+ await runTool(runArtifacts, "--check");
17
+ });
18
+ buddy.command("docs:links", "Report internal documentation links").action(async () => {
19
+ await runTool(runLinks);
20
+ });
21
+ buddy.command("docs:links:check", "Verify internal documentation links resolve").action(async () => {
22
+ await runTool(runLinks, "--check");
23
+ });
24
+ }
@@ -185,6 +185,14 @@ export function doctor(buddy) {
185
185
  const { config } = await import("@stacksjs/config");
186
186
  return `Driver: ${config.email?.default ?? "log"}`;
187
187
  });
188
+ {
189
+ const { ensureRuntimeDirectories, projectPath } = await import("@stacksjs/path"), leftover = ensureRuntimeDirectories().filter((entry) => !entry.cleared);
190
+ checks.push(leftover.length === 0 ? { name: "Runtime directories", status: "pass", message: "All under storage/" } : {
191
+ name: "Runtime directories",
192
+ status: "warn",
193
+ message: `Still in the project root: ${leftover.map((entry) => entry.legacy.slice(projectPath().length + 1)).join(", ")} \u2014 remove them so state stays under storage/`
194
+ });
195
+ }
188
196
  const hasAwsCreds = Boolean(process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY), hasAwsRole = Boolean(process.env.AWS_PROFILE) || Boolean(process.env.AWS_ROLE_ARN);
189
197
  if (hasAwsCreds || hasAwsRole)
190
198
  checks.push({
@@ -1,2 +1,16 @@
1
1
  import type { CLI } from '@stacksjs/types';
2
2
  export declare function env(buddy: CLI): void;
3
+ /**
4
+ * Parses `KEY=value` assignments out of a dotenv file into a flat map.
5
+ *
6
+ * Deliberately tolerant of what real `.env` files contain: comments, blank
7
+ * lines, `export ` prefixes, quoted values, and - the case that matters here -
8
+ * dotenvx ciphertext, which wraps across many lines inside its quotes. A naive
9
+ * `split('\n')` treats each wrapped fragment as its own line and loses the key
10
+ * it belongs to.
11
+ *
12
+ * Values are returned raw (still encrypted, if they were). `env:check` only
13
+ * needs the key names and whether a value is non-empty, so nothing here has to
14
+ * decrypt - which also means it works without the private key present.
15
+ */
16
+ export declare function parseEnvAssignments(content: string): Record<string, string>;
@@ -135,16 +135,14 @@ export function env(buddy) {
135
135
  message: "Found"
136
136
  });
137
137
  try {
138
- const envContent = await storage.readTextFile(envPath), lines = String(envContent).split(`
139
- `).filter((line) => line.trim() && !line.startsWith("#")), varCount = lines.length;
138
+ const envContent = await storage.readTextFile(envPath), contentStr = typeof envContent === "string" ? envContent : envContent.data, values = parseEnvAssignments(contentStr), keys = Object.keys(values), varCount = keys.length;
140
139
  checks.push({
141
140
  name: "Environment variables",
142
141
  status: "pass",
143
142
  message: `${varCount} variables defined`
144
143
  });
145
- if (lines.some((line) => line.startsWith("APP_KEY="))) {
146
- const appKeyLine = lines.find((line) => line.startsWith("APP_KEY=")), eq = appKeyLine?.indexOf("=") ?? -1, appKeyValue = (eq >= 0 ? appKeyLine.slice(eq + 1) : "").trim().replace(/^"(.*)"$/, "$1").replace(/^'(.*)'$/, "$1");
147
- if (appKeyValue && appKeyValue.length > 0)
144
+ if ("APP_KEY" in values)
145
+ if ((values.APP_KEY ?? "").length > 0)
148
146
  checks.push({
149
147
  name: "APP_KEY",
150
148
  status: "pass",
@@ -156,13 +154,13 @@ export function env(buddy) {
156
154
  status: "warn",
157
155
  message: "Empty (run: buddy key:generate)"
158
156
  });
159
- } else
157
+ else
160
158
  checks.push({
161
159
  name: "APP_KEY",
162
160
  status: "warn",
163
161
  message: "Not found (run: buddy key:generate)"
164
162
  });
165
- const hasPublicKey = lines.some((line) => line.startsWith("DOTENV_PUBLIC_KEY=")), hasPrivateKey = lines.some((line) => line.startsWith("DOTENV_PRIVATE_KEY="));
163
+ const hasPublicKey = keys.some((key) => key.startsWith("DOTENV_PUBLIC_KEY")), hasPrivateKey = keys.some((key) => key.startsWith("DOTENV_PRIVATE_KEY")) || existsSync(resolve(process.cwd(), ".env.keys"));
166
164
  if (hasPublicKey && hasPrivateKey)
167
165
  checks.push({
168
166
  name: "Encryption keys",
@@ -181,6 +179,41 @@ export function env(buddy) {
181
179
  status: "warn",
182
180
  message: "Not configured (optional)"
183
181
  });
182
+ const declaredTenants = await resolveDeclaredTenants();
183
+ if (declaredTenants.tenants.length === 0)
184
+ checks.push({
185
+ name: "Tenant isolation",
186
+ status: "pass",
187
+ message: "No tenants declared (cloud.tenants)"
188
+ });
189
+ else {
190
+ const { foreignTenantKeys, partitionTenantEnv } = await import("@stacksjs/env"), foreign = foreignTenantKeys(partitionTenantEnv(values, declaredTenants)), isShipped = /^\.env\.[a-z]+$/.test(envFile), total = foreign.reduce((sum, entry) => sum + entry.keys.length, 0);
191
+ if (foreign.length === 0)
192
+ checks.push({
193
+ name: "Tenant isolation",
194
+ status: "pass",
195
+ message: `No foreign keys (checked ${declaredTenants.tenants.join(", ")})`
196
+ });
197
+ else if (!isShipped)
198
+ checks.push({
199
+ name: "Tenant isolation",
200
+ status: "pass",
201
+ message: `${total} archived key(s) \u2014 ${envFile} is local-only and never deployed`
202
+ });
203
+ else {
204
+ checks.push({
205
+ name: "Tenant isolation",
206
+ status: "warn",
207
+ message: `${total} key(s) belong to another tenant \u2014 move them to .env`
208
+ });
209
+ for (const { tenant, keys } of foreign)
210
+ checks.push({
211
+ name: ` ${tenant}`,
212
+ status: "warn",
213
+ message: keys.join(", ")
214
+ });
215
+ }
216
+ }
184
217
  } catch (error) {
185
218
  checks.push({
186
219
  name: `${envFile} content`,
@@ -244,3 +277,36 @@ export function env(buddy) {
244
277
  });
245
278
  onUnknownSubcommand(buddy, "env");
246
279
  }
280
+ async function resolveDeclaredTenants() {
281
+ try {
282
+ const { config } = await import("@stacksjs/config"), cloud = config.cloud, app = config.app, tenants = Array.isArray(cloud?.tenants) ? cloud.tenants.filter((slug) => typeof slug === "string") : [];
283
+ return { self: app?.name, tenants };
284
+ } catch {
285
+ return { tenants: [] };
286
+ }
287
+ }
288
+ export function parseEnvAssignments(content) {
289
+ const values = {}, assignment = /^\s*(?:export\s+)?([A-Z_][A-Z0-9_]*)\s*=\s*(.*)$/i, lines = content.split(/\r?\n/);
290
+ for (let i = 0;i < lines.length; i++) {
291
+ const line = lines[i];
292
+ if (!line.trim() || line.trimStart().startsWith("#"))
293
+ continue;
294
+ const match = line.match(assignment);
295
+ if (!match)
296
+ continue;
297
+ const [, key, first] = match;
298
+ let raw = first.trim();
299
+ const quote = raw[0] === '"' || raw[0] === "'" ? raw[0] : void 0;
300
+ if (quote) {
301
+ raw = raw.slice(1);
302
+ while (!raw.endsWith(quote) && i + 1 < lines.length) {
303
+ i++;
304
+ raw += lines[i];
305
+ }
306
+ raw = raw.endsWith(quote) ? raw.slice(0, -1) : raw;
307
+ } else
308
+ raw = raw.replace(/\s+#.*$/, "").trim();
309
+ values[key] = raw;
310
+ }
311
+ return values;
312
+ }
@@ -96,7 +96,7 @@ export function generate(buddy) {
96
96
  process.exit(ExitCode.FatalError);
97
97
  }
98
98
  });
99
- buddy.command("generate:core-symlink", "Generate core symlink. A shortcut for core developers.").action(async (options) => {
99
+ buddy.command("generate:core-symlink", "Symlink `.framework` -> storage/framework. A shortcut for core developers.").action(async (options) => {
100
100
  log.debug("Running `buddy core-symlink` ...", options);
101
101
  await generateCoreSymlink();
102
102
  });
@@ -13,6 +13,7 @@ export * from './create';
13
13
  export * from './deploy';
14
14
  export * from './dev';
15
15
  export * from './dns';
16
+ export * from './docs';
16
17
  export * from './doctor';
17
18
  export * from './domains';
18
19
  export * from './email';
@@ -34,6 +35,7 @@ export * from './phone';
34
35
  export * from './ports';
35
36
  export * from './prepublish';
36
37
  export * from './projects';
38
+ export * from './protocol';
37
39
  export * from './publish';
38
40
  export * from './queue';
39
41
  export * from './release';
@@ -13,6 +13,7 @@ export * from "./create";
13
13
  export * from "./deploy";
14
14
  export * from "./dev";
15
15
  export * from "./dns";
16
+ export * from "./docs";
16
17
  export * from "./doctor";
17
18
  export * from "./domains";
18
19
  export * from "./email";
@@ -34,6 +35,7 @@ export * from "./phone";
34
35
  export * from "./ports";
35
36
  export * from "./prepublish";
36
37
  export * from "./projects";
38
+ export * from "./protocol";
37
39
  export * from "./publish";
38
40
  export * from "./queue";
39
41
  export * from "./release";
@@ -17,7 +17,7 @@ export function sanitizeLineCount(value) {
17
17
  const count = Number.parseInt(value || "50", 10);
18
18
  return String(Number.isFinite(count) ? Math.min(5000, Math.max(1, count)) : 50);
19
19
  }
20
- function shellQuote(_value) {
20
+ function shellQuote(value) {
21
21
  return `'${value.replace(/'/g, "'\\''")}'`;
22
22
  }
23
23
  async function resolveOperationalMailHost() {
@@ -3,7 +3,7 @@ import process from "node:process";
3
3
  import { confirm, intro, log, onUnknownSubcommand, outro, text } from "@stacksjs/cli";
4
4
  import { Action } from "@stacksjs/enums";
5
5
  import { hasTTY, isCI } from "@stacksjs/env";
6
- import { appPath, frameworkPath, projectPath } from "@stacksjs/path";
6
+ import { appPath, frameworkPath, frameworkRuntimePath, projectPath } from "@stacksjs/path";
7
7
  import { ExitCode } from "@stacksjs/types";
8
8
  let _runAction;
9
9
  async function runAction(...args) {
@@ -12,7 +12,7 @@ async function runAction(...args) {
12
12
  return _runAction(...args);
13
13
  }
14
14
  function acquireMigrationLock() {
15
- const lockDir = projectPath(".stacks"), lockFile = `${lockDir}/migrations.lock`;
15
+ const lockDir = frameworkRuntimePath(), lockFile = `${lockDir}/migrations.lock`;
16
16
  try {
17
17
  if (!existsSync(lockDir))
18
18
  mkdirSync(lockDir, { recursive: !0 });
@@ -36,7 +36,7 @@ function acquireMigrationLock() {
36
36
  }
37
37
  }
38
38
  function readMigrateMarker() {
39
- const file = projectPath(".stacks/last-migrate-result.json");
39
+ const file = frameworkRuntimePath("last-migrate-result.json");
40
40
  if (!existsSync(file))
41
41
  return null;
42
42
  try {
@@ -235,7 +235,7 @@ export function migrate(buddy) {
235
235
  }
236
236
  const lock = acquireMigrationLock();
237
237
  if (!lock.acquired) {
238
- log.syncError("Another migration is already running (.stacks/migrations.lock exists). Wait for it to finish, or remove the lockfile if it is stale.");
238
+ log.syncError("Another migration is already running (storage/framework/runtime/migrations.lock exists). Wait for it to finish, or remove the lockfile if it is stale.");
239
239
  process.exit(ExitCode.FatalError);
240
240
  }
241
241
  if (!await confirmDestructiveMigrations({ force: options.force, fromDb: options.fromDb, applyRenames })) {
@@ -0,0 +1,76 @@
1
+ export declare function validateCraftEvidence(evidence: typeof craftEvidence): string[];
2
+ export declare function run(): Promise<void>;
3
+ export declare const craftEvidence: {
4
+ schemaVersion: 1;
5
+ status: 'source-verified-ci-retained';
6
+ source: {
7
+ repository: 'https://github.com/home-lang/craft';
8
+ version: '0.0.48';
9
+ tag: 'v0.0.48';
10
+ revision: 'bf75807669e3af6cfb4ae34724be5c613442aee2'
11
+ };
12
+ issues: {
13
+ support: 'https://github.com/stacksjs/stacks/issues/2059';
14
+ signing: 'https://github.com/stacksjs/stacks/issues/2062';
15
+ lifecycle: 'https://github.com/stacksjs/stacks/issues/2063';
16
+ upstreamActions: 'https://github.com/home-lang/craft/issues/11';
17
+ dmgContention: 'https://github.com/home-lang/craft/issues/16'
18
+ };
19
+ contracts: readonly [{
20
+ path: 'packages/typescript/src/package.ts';
21
+ sha256: 'a7522f8c79dbd50a0f3bbc113525fb46eb8d6d1bab711d434cae85dc892ccdc1'
22
+ }, {
23
+ path: 'packages/typescript/src/package.test.ts';
24
+ sha256: '673338f3793e397a83590daf84815869b0698b4afe97eaad8039426418554b7a'
25
+ }, {
26
+ path: 'scripts/native-lifecycle.ts';
27
+ sha256: 'a2c0af2c2165981ca7fbd31880adeec64db64fc305d4ca3efe62a11483b289b4'
28
+ }, {
29
+ path: 'scripts/native-lifecycle-plan.ts';
30
+ sha256: 'ac8e8f101246e94c0c26fdcbf9202536426bf192a303606079273d9689e4e27d'
31
+ }, {
32
+ path: 'scripts/native-lifecycle-plan.test.ts';
33
+ sha256: '5cc839b60f7c8e32716f29201b159af817f4ac40b2c7963131124b3ac5de0638'
34
+ }, {
35
+ path: '.github/workflows/native-lifecycle.yml';
36
+ sha256: '493b614554f03dc669724ff31bd56ae8361851dd2a9b32ac37560d9c0bb1b85e'
37
+ }, {
38
+ path: '.github/workflows/release.yml';
39
+ sha256: 'e3c7edd45841a7260fb4dfe810f0ff3f63f3117f7d0f4d14984e4dfb36b1f638'
40
+ }];
41
+ lifecycleMatrix: readonly [{
42
+ runner: 'macos-15';
43
+ platform: 'darwin';
44
+ architecture: 'arm64';
45
+ formats: readonly ['dmg', 'pkg']
46
+ }, {
47
+ runner: 'macos-15-intel';
48
+ platform: 'darwin';
49
+ architecture: 'x64';
50
+ formats: readonly ['dmg', 'pkg']
51
+ }, {
52
+ runner: 'ubuntu-24.04';
53
+ platform: 'linux';
54
+ architecture: 'x64';
55
+ formats: readonly ['deb']
56
+ }, {
57
+ runner: 'windows-2025';
58
+ platform: 'win32';
59
+ architecture: 'x64';
60
+ formats: readonly ['msi', 'zip']
61
+ }];
62
+ lifecycleOperations: readonly ['install', 'launch', 'update', 'rollback', 'uninstall'];
63
+ stacksWorkflow: 'https://github.com/stacksjs/stacks/actions/workflows/desktop-lifecycle.yml';
64
+ release: {
65
+ sourceTagged: true;
66
+ githubRelease: null;
67
+ packagePublished: false;
68
+ signing: 'pending';
69
+ notarization: 'pending';
70
+ blockedBy: 'https://github.com/home-lang/craft/issues/11'
71
+ };
72
+ verification: {
73
+ local: readonly ['bun test packages/typescript/src/package.test.ts scripts/native-lifecycle-plan.test.ts', 'bun run typecheck', 'bun .github/scripts/native-lifecycle.ts'];
74
+ retained: 'https://github.com/stacksjs/stacks/actions/runs/29890804159'
75
+ }
76
+ };
@@ -0,0 +1,98 @@
1
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ const root = resolve(import.meta.dir, "../../../../../../.."), outputPath = resolve(root, ".github/protocol/evidence/craft.json");
4
+ export const craftEvidence = {
5
+ schemaVersion: 1,
6
+ status: "source-verified-ci-retained",
7
+ source: {
8
+ repository: "https://github.com/home-lang/craft",
9
+ version: "0.0.48",
10
+ tag: "v0.0.48",
11
+ revision: "bf75807669e3af6cfb4ae34724be5c613442aee2"
12
+ },
13
+ issues: {
14
+ support: "https://github.com/stacksjs/stacks/issues/2059",
15
+ signing: "https://github.com/stacksjs/stacks/issues/2062",
16
+ lifecycle: "https://github.com/stacksjs/stacks/issues/2063",
17
+ upstreamActions: "https://github.com/home-lang/craft/issues/11",
18
+ dmgContention: "https://github.com/home-lang/craft/issues/16"
19
+ },
20
+ contracts: [
21
+ { path: "packages/typescript/src/package.ts", sha256: "a7522f8c79dbd50a0f3bbc113525fb46eb8d6d1bab711d434cae85dc892ccdc1" },
22
+ { path: "packages/typescript/src/package.test.ts", sha256: "673338f3793e397a83590daf84815869b0698b4afe97eaad8039426418554b7a" },
23
+ { path: "scripts/native-lifecycle.ts", sha256: "a2c0af2c2165981ca7fbd31880adeec64db64fc305d4ca3efe62a11483b289b4" },
24
+ { path: "scripts/native-lifecycle-plan.ts", sha256: "ac8e8f101246e94c0c26fdcbf9202536426bf192a303606079273d9689e4e27d" },
25
+ { path: "scripts/native-lifecycle-plan.test.ts", sha256: "5cc839b60f7c8e32716f29201b159af817f4ac40b2c7963131124b3ac5de0638" },
26
+ { path: ".github/workflows/native-lifecycle.yml", sha256: "493b614554f03dc669724ff31bd56ae8361851dd2a9b32ac37560d9c0bb1b85e" },
27
+ { path: ".github/workflows/release.yml", sha256: "e3c7edd45841a7260fb4dfe810f0ff3f63f3117f7d0f4d14984e4dfb36b1f638" }
28
+ ],
29
+ lifecycleMatrix: [
30
+ { runner: "macos-15", platform: "darwin", architecture: "arm64", formats: ["dmg", "pkg"] },
31
+ { runner: "macos-15-intel", platform: "darwin", architecture: "x64", formats: ["dmg", "pkg"] },
32
+ { runner: "ubuntu-24.04", platform: "linux", architecture: "x64", formats: ["deb"] },
33
+ { runner: "windows-2025", platform: "win32", architecture: "x64", formats: ["msi", "zip"] }
34
+ ],
35
+ lifecycleOperations: ["install", "launch", "update", "rollback", "uninstall"],
36
+ stacksWorkflow: "https://github.com/stacksjs/stacks/actions/workflows/desktop-lifecycle.yml",
37
+ release: {
38
+ sourceTagged: !0,
39
+ githubRelease: null,
40
+ packagePublished: !1,
41
+ signing: "pending",
42
+ notarization: "pending",
43
+ blockedBy: "https://github.com/home-lang/craft/issues/11"
44
+ },
45
+ verification: {
46
+ local: ["bun test packages/typescript/src/package.test.ts scripts/native-lifecycle-plan.test.ts", "bun run typecheck", "bun .github/scripts/native-lifecycle.ts"],
47
+ retained: "https://github.com/stacksjs/stacks/actions/runs/29890804159"
48
+ }
49
+ };
50
+ export function validateCraftEvidence(evidence) {
51
+ const errors = [];
52
+ if (evidence.source.tag !== `v${evidence.source.version}`)
53
+ errors.push("Craft tag does not match its version");
54
+ if (!/^[a-f0-9]{40}$/.test(evidence.source.revision))
55
+ errors.push("Craft revision is not a full commit SHA");
56
+ if (evidence.contracts.length !== 7)
57
+ errors.push("all Craft packaging/release contracts must be pinned");
58
+ for (const contract of evidence.contracts)
59
+ if (!/^[a-f0-9]{64}$/.test(contract.sha256))
60
+ errors.push(`${contract.path}: invalid SHA-256 digest`);
61
+ const targets = new Set(evidence.lifecycleMatrix.map((row) => `${row.platform}/${row.architecture}`));
62
+ for (const target of ["darwin/arm64", "darwin/x64", "linux/x64", "win32/x64"])
63
+ if (!targets.has(target))
64
+ errors.push(`Craft lifecycle matrix is missing ${target}`);
65
+ if (evidence.release.signing !== "pending" || evidence.release.notarization !== "pending")
66
+ errors.push("Craft signing/notarization cannot be promoted without retained release evidence");
67
+ if (!evidence.release.blockedBy)
68
+ errors.push("Craft release blocker must be linked");
69
+ return errors;
70
+ }
71
+ function serializedEvidence() {
72
+ return `${JSON.stringify(craftEvidence, null, 2)}
73
+ `;
74
+ }
75
+ export async function run() {
76
+ const errors = validateCraftEvidence(craftEvidence);
77
+ if (process.argv.includes("--write")) {
78
+ if (errors.length)
79
+ throw Error(errors.join(`
80
+ `));
81
+ writeFileSync(outputPath, serializedEvidence());
82
+ console.log(`Wrote Craft evidence for ${craftEvidence.source.tag}`);
83
+ } else if (process.argv.includes("--check")) {
84
+ if (!existsSync(outputPath))
85
+ errors.push(".github/protocol/evidence/craft.json is missing");
86
+ else if (readFileSync(outputPath, "utf8") !== serializedEvidence())
87
+ errors.push("Craft evidence is stale; run bun run protocol:craft");
88
+ if (errors.length)
89
+ throw Error(errors.join(`
90
+ `));
91
+ console.log(`Craft evidence pins ${craftEvidence.source.tag} at ${craftEvidence.source.revision}`);
92
+ } else {
93
+ console.error("usage: bun storage/framework/core/buddy/src/commands/protocol/craft-evidence.ts --write | --check");
94
+ process.exit(2);
95
+ }
96
+ }
97
+ if (import.meta.main)
98
+ await run();
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,20 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { craftEvidence, validateCraftEvidence } from "./craft-evidence";
3
+ describe("Craft protocol evidence", () => {
4
+ it("pins the full native lifecycle and release contract", () => {
5
+ expect(validateCraftEvidence(craftEvidence)).toEqual([]);
6
+ expect(craftEvidence.lifecycleMatrix).toHaveLength(4);
7
+ expect(craftEvidence.lifecycleOperations).toEqual(["install", "launch", "update", "rollback", "uninstall"]);
8
+ });
9
+ it("rejects untraceable source and premature signing claims", () => {
10
+ const invalid = structuredClone(craftEvidence);
11
+ Object.assign(invalid.source, { tag: "latest", revision: "short" });
12
+ Object.assign(invalid.release, { signing: "enforced", notarization: "enforced", blockedBy: "" });
13
+ expect(validateCraftEvidence(invalid)).toEqual(expect.arrayContaining([
14
+ "Craft tag does not match its version",
15
+ "Craft revision is not a full commit SHA",
16
+ "Craft signing/notarization cannot be promoted without retained release evidence",
17
+ "Craft release blocker must be linked"
18
+ ]));
19
+ });
20
+ });
@@ -0,0 +1,16 @@
1
+ export declare function validateLifecycleReport(report: LifecycleReport, expected: {
2
+ revision: string
3
+ platform: string
4
+ architecture: string
5
+ formats: string[]
6
+ }): string[];
7
+ export declare function run(): Promise<void>;
8
+ declare type LifecycleReport = {
9
+ revision?: string
10
+ orchestratorRevision?: string | null
11
+ status?: string
12
+ installLifecycleExercised?: boolean
13
+ runner?: { os?: string, arch?: string }
14
+ artifacts?: Array<{ format?: string, sha256?: string }>
15
+ steps?: Array<{ name?: string, status?: string }>
16
+ }
@@ -0,0 +1,57 @@
1
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname, resolve } from "node:path";
3
+ export function validateLifecycleReport(report, expected) {
4
+ const errors = [];
5
+ if (report.revision !== expected.revision)
6
+ errors.push(`Craft source revision mismatch: ${report.revision || "missing"}`);
7
+ if (report.status !== "passed")
8
+ errors.push(`Craft lifecycle status is ${report.status || "missing"}`);
9
+ if (report.installLifecycleExercised !== !0)
10
+ errors.push("privileged install lifecycle was not exercised");
11
+ if (report.runner?.os !== expected.platform || report.runner?.arch !== expected.architecture)
12
+ errors.push(`runner mismatch: ${report.runner?.os || "missing"}/${report.runner?.arch || "missing"}`);
13
+ const steps = new Map((report.steps || []).map((step) => [step.name, step.status]));
14
+ for (const name of ["install v1", "launch v1", "update to v2", "launch v2", "rollback to v1", "launch rollback", "uninstall", "verify uninstall"])
15
+ if (steps.get(name) !== "passed")
16
+ errors.push(`lifecycle step did not pass: ${name}`);
17
+ const artifacts = report.artifacts || [];
18
+ for (const format of expected.formats) {
19
+ const matching = artifacts.filter((artifact) => artifact.format === format);
20
+ if (matching.length !== 2)
21
+ errors.push(`expected two ${format} artifacts, found ${matching.length}`);
22
+ if (matching.some((artifact) => !/^[a-f0-9]{64}$/.test(artifact.sha256 || "")))
23
+ errors.push(`${format} artifact has an invalid digest`);
24
+ }
25
+ return errors;
26
+ }
27
+ export async function run() {
28
+ const value = (name) => {
29
+ const index = process.argv.indexOf(name), result = index === -1 ? void 0 : process.argv[index + 1];
30
+ if (!result)
31
+ throw Error(`${name} requires a value`);
32
+ return result;
33
+ }, reportPath = resolve(value("--report")), outputPath = resolve(value("--output")), expected = {
34
+ revision: value("--revision"),
35
+ platform: value("--platform"),
36
+ architecture: value("--architecture"),
37
+ formats: value("--formats").split(",")
38
+ }, report = JSON.parse(readFileSync(reportPath, "utf8")), errors = validateLifecycleReport(report, expected);
39
+ if (errors.length)
40
+ throw Error(errors.join(`
41
+ `));
42
+ const attestation = {
43
+ schemaVersion: 1,
44
+ status: "passed",
45
+ stacksRevision: process.env.GITHUB_SHA || "local",
46
+ craftRevision: expected.revision,
47
+ sourceReport: reportPath,
48
+ target: { platform: expected.platform, architecture: expected.architecture, formats: expected.formats },
49
+ checks: { lifecycle: "passed", artifactDigests: "passed", sourcePin: "passed" }
50
+ };
51
+ mkdirSync(dirname(outputPath), { recursive: !0 });
52
+ writeFileSync(outputPath, `${JSON.stringify(attestation, null, 2)}
53
+ `);
54
+ console.log(`Validated Craft lifecycle for ${expected.platform}/${expected.architecture}`);
55
+ }
56
+ if (import.meta.main)
57
+ await run();