@devdogsuga/devtools 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (253) hide show
  1. package/README.md +44 -0
  2. package/bin/devtools-ci-bare.mjs +48 -0
  3. package/bin/devtools-ci.mjs +17 -0
  4. package/bin/devtools.mjs +27 -0
  5. package/dist/args.d.ts +26 -0
  6. package/dist/args.js +78 -0
  7. package/dist/bws/bw.d.ts +9 -0
  8. package/dist/bws/bw.js +49 -0
  9. package/dist/bws/client.d.ts +43 -0
  10. package/dist/bws/client.js +363 -0
  11. package/dist/bws/environments.d.ts +96 -0
  12. package/dist/bws/environments.js +111 -0
  13. package/dist/bws/pace.d.ts +27 -0
  14. package/dist/bws/pace.js +43 -0
  15. package/dist/bws/retry.d.ts +12 -0
  16. package/dist/bws/retry.js +39 -0
  17. package/dist/bws/store.d.ts +1 -0
  18. package/dist/bws/store.js +32 -0
  19. package/dist/bws/token.d.ts +31 -0
  20. package/dist/bws/token.js +88 -0
  21. package/dist/bws/vault.d.ts +29 -0
  22. package/dist/bws/vault.js +200 -0
  23. package/dist/catalog.d.ts +55 -0
  24. package/dist/catalog.js +81 -0
  25. package/dist/cf/commands.d.ts +1 -0
  26. package/dist/cf/commands.js +182 -0
  27. package/dist/cf/local-env.d.ts +44 -0
  28. package/dist/cf/local-env.js +148 -0
  29. package/dist/ci.d.ts +1 -0
  30. package/dist/ci.js +319 -0
  31. package/dist/cli.d.ts +11 -0
  32. package/dist/cli.js +1077 -0
  33. package/dist/commands.d.ts +255 -0
  34. package/dist/commands.js +1279 -0
  35. package/dist/completions.d.ts +4 -0
  36. package/dist/completions.js +110 -0
  37. package/dist/cron/commands.d.ts +47 -0
  38. package/dist/cron/commands.js +372 -0
  39. package/dist/cron/discovery.d.ts +55 -0
  40. package/dist/cron/discovery.js +85 -0
  41. package/dist/cron/schema.d.ts +45 -0
  42. package/dist/cron/schema.js +35 -0
  43. package/dist/db/config-push.d.ts +8 -0
  44. package/dist/db/config-push.js +11 -0
  45. package/dist/db/connection.d.ts +79 -0
  46. package/dist/db/connection.js +114 -0
  47. package/dist/db/exec.d.ts +1 -0
  48. package/dist/db/exec.js +11 -0
  49. package/dist/db/generate-migration.d.ts +1 -0
  50. package/dist/db/generate-migration.js +67 -0
  51. package/dist/db/generate-types.d.ts +1 -0
  52. package/dist/db/generate-types.js +4 -0
  53. package/dist/db/introspect.d.ts +1 -0
  54. package/dist/db/introspect.js +113 -0
  55. package/dist/db/local-env.d.ts +65 -0
  56. package/dist/db/local-env.js +23 -0
  57. package/dist/db/new-migration.d.ts +1 -0
  58. package/dist/db/new-migration.js +8 -0
  59. package/dist/db/run.d.ts +70 -0
  60. package/dist/db/run.js +158 -0
  61. package/dist/db/seed-buckets.d.ts +2 -0
  62. package/dist/db/seed-buckets.js +4 -0
  63. package/dist/db/seed-production.d.ts +8 -0
  64. package/dist/db/seed-production.js +73 -0
  65. package/dist/db/seed-roles.d.ts +7 -0
  66. package/dist/db/seed-roles.js +31 -0
  67. package/dist/db/session-refresh.d.ts +43 -0
  68. package/dist/db/session-refresh.js +40 -0
  69. package/dist/deploy/migrations.d.ts +20 -0
  70. package/dist/deploy/migrations.js +68 -0
  71. package/dist/deploy/orphans.d.ts +20 -0
  72. package/dist/deploy/orphans.js +177 -0
  73. package/dist/deploy/preflight.d.ts +53 -0
  74. package/dist/deploy/preflight.js +189 -0
  75. package/dist/deploy/report.d.ts +26 -0
  76. package/dist/deploy/report.js +70 -0
  77. package/dist/deploy/require-planner.d.ts +2 -0
  78. package/dist/deploy/require-planner.js +60 -0
  79. package/dist/deploy/require-token.d.ts +15 -0
  80. package/dist/deploy/require-token.js +78 -0
  81. package/dist/deploy/secrets-file.d.ts +17 -0
  82. package/dist/deploy/secrets-file.js +167 -0
  83. package/dist/deploy/write-env.d.ts +40 -0
  84. package/dist/deploy/write-env.js +393 -0
  85. package/dist/docs/index-pages.d.ts +36 -0
  86. package/dist/docs/index-pages.js +184 -0
  87. package/dist/doctor.d.ts +62 -0
  88. package/dist/doctor.js +225 -0
  89. package/dist/emails/commands.d.ts +15 -0
  90. package/dist/emails/commands.js +130 -0
  91. package/dist/emails/email-types.d.ts +13 -0
  92. package/dist/emails/email-types.js +1 -0
  93. package/dist/emails/fixtures.d.ts +20 -0
  94. package/dist/emails/fixtures.js +20 -0
  95. package/dist/env/audit.d.ts +206 -0
  96. package/dist/env/audit.js +436 -0
  97. package/dist/env/cloudflare.d.ts +21 -0
  98. package/dist/env/cloudflare.js +61 -0
  99. package/dist/env/commands.d.ts +99 -0
  100. package/dist/env/commands.js +612 -0
  101. package/dist/env/discovery.d.ts +15 -0
  102. package/dist/env/discovery.js +226 -0
  103. package/dist/env/document.d.ts +119 -0
  104. package/dist/env/document.js +323 -0
  105. package/dist/env/example.d.ts +68 -0
  106. package/dist/env/example.js +603 -0
  107. package/dist/env/selection.d.ts +105 -0
  108. package/dist/env/selection.js +226 -0
  109. package/dist/environment.d.ts +92 -0
  110. package/dist/environment.js +206 -0
  111. package/dist/fingerprint.d.ts +10 -0
  112. package/dist/fingerprint.js +18 -0
  113. package/dist/gen/campus-map.d.ts +1 -0
  114. package/dist/gen/campus-map.js +473 -0
  115. package/dist/gen/commands.d.ts +1 -0
  116. package/dist/gen/commands.js +25 -0
  117. package/dist/gen/hypno.d.ts +1 -0
  118. package/dist/gen/hypno.js +134 -0
  119. package/dist/gh/client.d.ts +80 -0
  120. package/dist/gh/client.js +258 -0
  121. package/dist/gh/environments.d.ts +83 -0
  122. package/dist/gh/environments.js +178 -0
  123. package/dist/gh/rulesets/actors.d.ts +14 -0
  124. package/dist/gh/rulesets/actors.js +68 -0
  125. package/dist/gh/rulesets/api.d.ts +15 -0
  126. package/dist/gh/rulesets/api.js +99 -0
  127. package/dist/gh/rulesets/commands.d.ts +3 -0
  128. package/dist/gh/rulesets/commands.js +203 -0
  129. package/dist/gh/rulesets/desired.d.ts +112 -0
  130. package/dist/gh/rulesets/desired.js +247 -0
  131. package/dist/gh/rulesets/diff.d.ts +65 -0
  132. package/dist/gh/rulesets/diff.js +134 -0
  133. package/dist/gh/rulesets/types.d.ts +76 -0
  134. package/dist/gh/rulesets/types.js +13 -0
  135. package/dist/gh/settings/api.d.ts +40 -0
  136. package/dist/gh/settings/api.js +172 -0
  137. package/dist/gh/settings/commands.d.ts +1 -0
  138. package/dist/gh/settings/commands.js +245 -0
  139. package/dist/gh/settings/desired.d.ts +53 -0
  140. package/dist/gh/settings/desired.js +39 -0
  141. package/dist/gh/settings/diff.d.ts +57 -0
  142. package/dist/gh/settings/diff.js +156 -0
  143. package/dist/gh/settings/types.d.ts +92 -0
  144. package/dist/gh/settings/types.js +11 -0
  145. package/dist/gh/settings/workflows.d.ts +41 -0
  146. package/dist/gh/settings/workflows.js +124 -0
  147. package/dist/grantRoot.d.ts +42 -0
  148. package/dist/grantRoot.js +89 -0
  149. package/dist/help.d.ts +18 -0
  150. package/dist/help.js +169 -0
  151. package/dist/images/commands.d.ts +35 -0
  152. package/dist/images/commands.js +233 -0
  153. package/dist/images/events.d.ts +36 -0
  154. package/dist/images/events.js +152 -0
  155. package/dist/images/graphics.d.ts +70 -0
  156. package/dist/images/graphics.js +233 -0
  157. package/dist/images/open-graph-types.d.ts +75 -0
  158. package/dist/images/open-graph-types.js +1 -0
  159. package/dist/images/prompts.d.ts +34 -0
  160. package/dist/images/prompts.js +100 -0
  161. package/dist/images/render.d.ts +21 -0
  162. package/dist/images/render.js +57 -0
  163. package/dist/images/select.d.ts +64 -0
  164. package/dist/images/select.js +96 -0
  165. package/dist/instance.d.ts +67 -0
  166. package/dist/instance.js +129 -0
  167. package/dist/invocation.d.ts +49 -0
  168. package/dist/invocation.js +78 -0
  169. package/dist/launch-ci.d.ts +1 -0
  170. package/dist/launch-ci.js +86 -0
  171. package/dist/launch.d.ts +29 -0
  172. package/dist/launch.js +371 -0
  173. package/dist/menu.d.ts +31 -0
  174. package/dist/menu.js +277 -0
  175. package/dist/newsletter/commands.d.ts +16 -0
  176. package/dist/newsletter/commands.js +311 -0
  177. package/dist/newsletter/imap.d.ts +17 -0
  178. package/dist/newsletter/imap.js +127 -0
  179. package/dist/newsletter/loopback.d.ts +24 -0
  180. package/dist/newsletter/loopback.js +100 -0
  181. package/dist/newsletter/oauth.d.ts +26 -0
  182. package/dist/newsletter/oauth.js +130 -0
  183. package/dist/newsletter/smtp.d.ts +23 -0
  184. package/dist/newsletter/smtp.js +133 -0
  185. package/dist/oauth/browser.d.ts +1 -0
  186. package/dist/oauth/browser.js +13 -0
  187. package/dist/oauth/config.d.ts +24 -0
  188. package/dist/oauth/config.js +24 -0
  189. package/dist/oauth/connect-url.d.ts +17 -0
  190. package/dist/oauth/connect-url.js +23 -0
  191. package/dist/oauth/db.d.ts +37 -0
  192. package/dist/oauth/db.js +108 -0
  193. package/dist/oauth/device.d.ts +93 -0
  194. package/dist/oauth/device.js +190 -0
  195. package/dist/oauth/discovery.d.ts +28 -0
  196. package/dist/oauth/discovery.js +54 -0
  197. package/dist/oauth/env-file.d.ts +6 -0
  198. package/dist/oauth/env-file.js +35 -0
  199. package/dist/oauth/exchange.d.ts +43 -0
  200. package/dist/oauth/exchange.js +87 -0
  201. package/dist/oauth/label.d.ts +1 -0
  202. package/dist/oauth/label.js +25 -0
  203. package/dist/oauth/loopback.d.ts +52 -0
  204. package/dist/oauth/loopback.js +162 -0
  205. package/dist/oauth/pkce.d.ts +6 -0
  206. package/dist/oauth/pkce.js +34 -0
  207. package/dist/oauth/target.d.ts +54 -0
  208. package/dist/oauth/target.js +102 -0
  209. package/dist/oauth/transport.d.ts +41 -0
  210. package/dist/oauth/transport.js +32 -0
  211. package/dist/oauth/wizard.d.ts +33 -0
  212. package/dist/oauth/wizard.js +571 -0
  213. package/dist/pick.d.ts +6 -0
  214. package/dist/pick.js +64 -0
  215. package/dist/planner/checks.d.ts +26 -0
  216. package/dist/planner/checks.js +104 -0
  217. package/dist/planner/commands.d.ts +10 -0
  218. package/dist/planner/commands.js +367 -0
  219. package/dist/planner/db.d.ts +15 -0
  220. package/dist/planner/db.js +33 -0
  221. package/dist/planner/role.d.ts +33 -0
  222. package/dist/planner/role.js +68 -0
  223. package/dist/repo/peers.d.ts +24 -0
  224. package/dist/repo/peers.js +114 -0
  225. package/dist/repo/preflight.d.ts +109 -0
  226. package/dist/repo/preflight.js +266 -0
  227. package/dist/repo/resolve.d.ts +41 -0
  228. package/dist/repo/resolve.js +152 -0
  229. package/dist/repo/root.d.ts +23 -0
  230. package/dist/repo/root.js +91 -0
  231. package/dist/repo/source.d.ts +14 -0
  232. package/dist/repo/source.js +54 -0
  233. package/dist/repo/tsx-loader.d.ts +6 -0
  234. package/dist/repo/tsx-loader.js +41 -0
  235. package/dist/run/pick.d.ts +60 -0
  236. package/dist/run/pick.js +559 -0
  237. package/dist/setup.d.ts +1 -0
  238. package/dist/setup.js +152 -0
  239. package/dist/stack.d.ts +48 -0
  240. package/dist/stack.js +290 -0
  241. package/dist/telemetry.d.ts +34 -0
  242. package/dist/telemetry.js +93 -0
  243. package/dist/tier.d.ts +40 -0
  244. package/dist/tier.js +93 -0
  245. package/dist/tsconfig.typecheck.tsbuildinfo +1 -0
  246. package/dist/ui.d.ts +25 -0
  247. package/dist/ui.js +51 -0
  248. package/dist/workers.d.ts +5 -0
  249. package/dist/workers.js +35 -0
  250. package/dist/workflows/commands.d.ts +44 -0
  251. package/dist/workflows/commands.js +668 -0
  252. package/env.ts +158 -0
  253. package/package.json +80 -0
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Loads devtools' optional-peer `@devdogsuga/*` libraries — the ones
3
+ * published to npm and consumed by the TARGET repo, which devtools reads at
4
+ * runtime rather than bundling its own copy of (see this package's
5
+ * `package.json`: `peerDependencies` + `peerDependenciesMeta.optional`, and
6
+ * `FINDINGS.md` experiment 2).
7
+ *
8
+ * Every export here is memoized per module (one dynamic `import()` per
9
+ * process, not per call) — this is what makes the module-identity guarantee
10
+ * in FINDINGS experiment 3 hold: every devtools call site that needs
11
+ * `@devdogsuga/env`'s registry gets the exact same module instance the
12
+ * repo's own manifests populated via `declare()`/`define()`, because both
13
+ * resolve to the identical absolute file path and Node's ESM cache is keyed
14
+ * by resolved URL.
15
+ */
16
+ import { pathToFileURL } from "node:url";
17
+ import { findRepoRoot, RepoNotFoundError } from "./root.js";
18
+ import { findDependent, resolveFromRepo } from "./resolve.js";
19
+ const moduleCache = new Map();
20
+ /**
21
+ * Dynamically imports `specifier` as the repo would resolve it, memoized so
22
+ * repeated calls in one process return the SAME module instance (required
23
+ * for the env-registry identity guarantee above).
24
+ *
25
+ * Falls back to a plain bare-specifier `import()` when devtools is not
26
+ * running inside a DevDogsUGA checkout at all (`findRepoRoot()` throws
27
+ * `RepoNotFoundError`). That is not a production affordance — under real
28
+ * `pnpm dlx` use, `specifier` is an optional peer devtools' own package
29
+ * never installs, so the fallback import fails with the ordinary
30
+ * `MODULE_NOT_FOUND` either way. It exists so devtools' OWN test suite
31
+ * (which has no target repo to discover, but does have every one of these
32
+ * peers as a real Backstage workspace package) exercises this code against
33
+ * the genuine package rather than needing every test to mock this module.
34
+ */
35
+ function loadPeer(specifier) {
36
+ const cached = moduleCache.get(specifier);
37
+ if (cached)
38
+ return cached;
39
+ const promise = (async () => {
40
+ // See `findRepoRoot()`'s own comment: Backstage's test suite sets this
41
+ // so every peer resolves to the real Backstage-workspace copy instead
42
+ // of a nonexistent target repo, without a per-test mock.
43
+ if (process.env.DEVTOOLS_TEST_REPO_ROOT) {
44
+ return import(specifier);
45
+ }
46
+ let repoRoot;
47
+ try {
48
+ repoRoot = findRepoRoot();
49
+ }
50
+ catch (err) {
51
+ if (err instanceof RepoNotFoundError) {
52
+ return import(specifier);
53
+ }
54
+ throw err;
55
+ }
56
+ const resolutionBase = findDependent(repoRoot, specifier);
57
+ if (!resolutionBase) {
58
+ throw new Error(`Nothing in this repo depends on ${specifier} — expected an app or package under apps/*` +
59
+ ` or packages/* to declare it (dependencies/devDependencies/peerDependencies).`);
60
+ }
61
+ const { resolvedPath } = resolveFromRepo(repoRoot, resolutionBase, specifier);
62
+ return import(pathToFileURL(resolvedPath).href);
63
+ })();
64
+ moduleCache.set(specifier, promise);
65
+ return promise;
66
+ }
67
+ /** Test-only: clears the memoized peer modules so a test can re-resolve with a fresh mock. */
68
+ export function resetPeerCacheForTests() {
69
+ moduleCache.clear();
70
+ }
71
+ let resolvedEnvModule;
72
+ export function loadEnv() {
73
+ return loadPeer("@devdogsuga/env").then((mod) => {
74
+ resolvedEnvModule = mod;
75
+ return mod;
76
+ });
77
+ }
78
+ /**
79
+ * The synchronous companion to `loadEnv()`, for the handful of call sites
80
+ * (`gh/environments.ts`'s `GITHUB_ENVIRONMENT_SPECS` getters) that cannot
81
+ * become async without changing their own callers' contract — they read a
82
+ * plain array off a getter, not a Promise. Safe ONLY after `loadEnv()` has
83
+ * resolved at least once; every caller of these getters already requires
84
+ * `env/discovery.ts`'s `loadRegistry()` (which calls `loadEnv()` itself) to
85
+ * have run first, via `assertRegistryLoaded()`'s own guard.
86
+ */
87
+ export function getEnvSync() {
88
+ if (!resolvedEnvModule) {
89
+ throw new Error("getEnvSync() called before loadEnv() ever resolved — call loadRegistry() " +
90
+ "(env/discovery.ts) first.");
91
+ }
92
+ return resolvedEnvModule;
93
+ }
94
+ /** Test-only: clears the sync cache alongside `resetPeerCacheForTests()`. */
95
+ export function resetEnvSyncCacheForTests() {
96
+ resolvedEnvModule = undefined;
97
+ }
98
+ export function loadEnvLoad() {
99
+ return loadPeer("@devdogsuga/env/load");
100
+ }
101
+ export function loadEnvSession() {
102
+ return loadPeer("@devdogsuga/env/session");
103
+ }
104
+ export function loadDbTypegen() {
105
+ return loadPeer("@devdogsuga/db/typegen");
106
+ }
107
+ export function loadBrandEvent() {
108
+ return loadPeer("@devdogsuga/brand/event");
109
+ }
110
+ // `@devdogsuga/newsletter` used to be an optional peer here, read by
111
+ // devtools' own `newsletter` command. Wave 2, stage A2 moved that command to
112
+ // a plain Backstage script (`packages/newsletter-cli`) — see its README —
113
+ // since the content it exports lives in Backstage already; devtools has no
114
+ // reason to load it at all any more.
@@ -0,0 +1,109 @@
1
+ import { z } from "zod";
2
+ declare const ManifestSchema: z.ZodObject<{
3
+ devtools: z.ZodObject<{
4
+ latest: z.ZodString;
5
+ minimum: z.ZodString;
6
+ }, z.core.$strip>;
7
+ }, z.core.$strip>;
8
+ export type DevtoolsMinimumsManifest = z.infer<typeof ManifestSchema>;
9
+ /** Exported so tests can point elsewhere without touching the real `~/.cache`. */
10
+ export declare function cacheFilePath(): string;
11
+ export interface ManifestResult {
12
+ manifest: DevtoolsMinimumsManifest | null;
13
+ source: "cache" | "network" | "fail-open";
14
+ error?: string;
15
+ }
16
+ /**
17
+ * Fetches (or reads the disk cache for) the minimums manifest. Never
18
+ * throws: any failure — timeout, network error, non-200, invalid JSON,
19
+ * schema mismatch — comes back as `{ manifest: null, source: "fail-open" }`.
20
+ */
21
+ export declare function fetchManifest(options?: {
22
+ url?: string;
23
+ skipCache?: boolean;
24
+ timeoutMs?: number;
25
+ }): Promise<ManifestResult>;
26
+ export type PreflightAction = {
27
+ action: "skip-preflight";
28
+ } | {
29
+ action: "dev-mode";
30
+ } | {
31
+ action: "fail-open";
32
+ reason: string;
33
+ } | {
34
+ action: "self-refresh";
35
+ from: string;
36
+ to: string;
37
+ source: ManifestResult["source"];
38
+ } | {
39
+ action: "loop-guard-tripped";
40
+ detail: string;
41
+ } | {
42
+ action: "nudge";
43
+ from: string;
44
+ to: string;
45
+ source: ManifestResult["source"];
46
+ } | {
47
+ action: "up-to-date";
48
+ source: ManifestResult["source"];
49
+ };
50
+ export interface PreflightOptions {
51
+ currentVersion: string;
52
+ argv: readonly string[];
53
+ skipPreflight: boolean;
54
+ refresh: boolean;
55
+ registry?: string;
56
+ manifestUrl?: string;
57
+ devMode?: boolean;
58
+ }
59
+ /**
60
+ * Runs the decision half of preflight. Does not itself re-exec or print —
61
+ * `runPreflight` below does both, so the pure decision stays independently
62
+ * testable (loop guard, fail-open, version comparisons) without spawning a
63
+ * real child process in every test.
64
+ */
65
+ export declare function decidePreflight(options: PreflightOptions): Promise<PreflightAction>;
66
+ /**
67
+ * Re-execs as the exact pinned `version`, inheriting stdio and propagating
68
+ * the child's exit code. Never returns — the caller relies on this to end
69
+ * the process, matching `spawnSync`'s synchronous nature (there is nothing
70
+ * meaningful left to do in this process either way).
71
+ *
72
+ * Exported separately from `runPreflight` so tests can stub it instead of
73
+ * spawning a real `pnpm dlx`.
74
+ */
75
+ export declare function selfRefresh(options: {
76
+ version: string;
77
+ argv: readonly string[];
78
+ registry?: string;
79
+ }): never;
80
+ /**
81
+ * The full preflight: decides, prints the single line each outcome calls
82
+ * for (or nothing, for the common `up-to-date`/`skip-preflight`/`dev-mode`
83
+ * cases — see each branch), and re-execs on `self-refresh`. Returns once
84
+ * for every other outcome so `launch()` can continue dispatching the
85
+ * original command.
86
+ */
87
+ export declare function runPreflight(options: PreflightOptions & {
88
+ reexec?: typeof selfRefresh;
89
+ }): Promise<PreflightAction>;
90
+ /** The directory this module's compiled file lives in one level under —
91
+ * i.e. the devtools package's own root (`dist/repo/preflight.js` → up two).
92
+ * Exported for tests; memoized since it never changes within a process. */
93
+ export declare function ownPackageDir(): string;
94
+ /** Reads this build's own `package.json` — the version preflight compares
95
+ * against the manifest, and the value dev-mode detection inspects. */
96
+ export declare function ownVersion(): string;
97
+ /**
98
+ * True when this devtools is running from a workspace/dev checkout rather
99
+ * than a published install — a `pnpm build && node ./bin/devtools.mjs` run
100
+ * inside `Backstage/packages/devtools` itself, or any other case where the
101
+ * package's own directory is not sitting under a `node_modules` tree (a
102
+ * real install — via dlx's isolated cache or an ordinary `node_modules` —
103
+ * always puts it under one). `version: "0.0.0-dev"` is the explicit,
104
+ * unambiguous override for anything that wants to force this (a fixture,
105
+ * an integration test); the path heuristic is the sensible default so
106
+ * nobody has to remember to set it.
107
+ */
108
+ export declare function isDevMode(version?: string, dir?: string): boolean;
109
+ export {};
@@ -0,0 +1,266 @@
1
+ /**
2
+ * The dlx-distribution preflight: fetches a tiny minimums manifest from
3
+ * DevDogsUGA's `main` branch, compares it against this build's own version,
4
+ * and decides whether to proceed, nudge, or self-refresh by re-exec'ing
5
+ * `pnpm dlx @devdogsuga/devtools@<latest>`.
6
+ *
7
+ * Ported from the `devtools-dlx` prototype
8
+ * (`/home/sloan/scratchpad/devdogs/prototypes/devtools-dlx/FINDINGS.md`,
9
+ * experiment 7 and its "Recommended contract" section) — read that file for
10
+ * why each design choice below is the one that survived testing rather than
11
+ * the first thing that seemed reasonable:
12
+ *
13
+ * - The re-exec spec MUST be an exact version (`@devdogsuga/devtools@0.4.2`),
14
+ * never a bare spec. Experiment 6 found TWO independent staleness
15
+ * mechanisms a bare spec (even with `dlx-cache-max-age=0`) does not
16
+ * reliably bypass: pnpm's dlx cache TTL, and pnpm 11's
17
+ * `minimumReleaseAge` cooldown on a just-published version. Only an
18
+ * exact pinned spec bypasses both.
19
+ * - `--config.registry=<url>`, not `--registry` after `dlx` — pnpm 11
20
+ * rejects the latter positioned there.
21
+ * - The manifest fetch fails open (log + proceed on the current version)
22
+ * on ANY error — dead host, timeout, malformed JSON, schema mismatch —
23
+ * so an offline contributor is never blocked by this check.
24
+ *
25
+ * `devtools-ci` (`launch-ci.ts`) never calls this module — CI pins an exact
26
+ * version on purpose (see the carve-out plan's §9 Wave 2 decisions), and a
27
+ * self-refreshing CI job would defeat that pin.
28
+ */
29
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
30
+ import { spawnSync } from "node:child_process";
31
+ import { homedir } from "node:os";
32
+ import { dirname, join, sep } from "node:path";
33
+ import { fileURLToPath } from "node:url";
34
+ import { z } from "zod";
35
+ /** Env var set on the re-exec'd child so a still-below-minimum child refuses
36
+ * to loop a second time instead of re-exec'ing forever. */
37
+ const LOOP_GUARD_ENV = "DEVTOOLS_REEXEC_GUARD";
38
+ /** Default location of the manifest — a repo-root file in DevDogsUGA's own
39
+ * `main` branch, not buried inside a package, so it survives reorganization
40
+ * and is a trivial hotfix-PR edit. Overridable for tests and for use before
41
+ * the file exists on the real remote. */
42
+ const DEFAULT_MINIMUMS_URL = "https://raw.githubusercontent.com/DevDogsUGA/DevDogsUGA/main/devtools-minimums.json";
43
+ const FETCH_TIMEOUT_MS = 1500;
44
+ /**
45
+ * Deliberately short — 10 minutes, not the plan's original "a few hours" —
46
+ * so a mid-meeting hotfix (someone drops the `minimum` to force a
47
+ * self-refresh) propagates to every contributor's next command within one
48
+ * coffee break, not one workday. This is the one place stage A2 knowingly
49
+ * overrides §5's original wording; see the carve-out plan's §9 note.
50
+ */
51
+ const CACHE_TTL_MS = 10 * 60 * 1000;
52
+ const ManifestSchema = z.object({
53
+ devtools: z.object({
54
+ latest: z.string().min(1),
55
+ minimum: z.string().min(1),
56
+ }),
57
+ });
58
+ function xdgCacheHome() {
59
+ const fromEnv = process.env.XDG_CACHE_HOME;
60
+ return fromEnv !== undefined && fromEnv !== "" ? fromEnv : join(homedir(), ".cache");
61
+ }
62
+ /** Exported so tests can point elsewhere without touching the real `~/.cache`. */
63
+ export function cacheFilePath() {
64
+ return join(xdgCacheHome(), "devdogsuga-devtools", "minimums.json");
65
+ }
66
+ function readCache(url) {
67
+ const file = cacheFilePath();
68
+ if (!existsSync(file))
69
+ return null;
70
+ try {
71
+ const entry = JSON.parse(readFileSync(file, "utf8"));
72
+ if (entry.url !== url)
73
+ return null;
74
+ if (Date.now() - entry.fetchedAt > CACHE_TTL_MS)
75
+ return null;
76
+ const parsed = ManifestSchema.safeParse(entry.manifest);
77
+ return parsed.success ? parsed.data : null;
78
+ }
79
+ catch {
80
+ return null;
81
+ }
82
+ }
83
+ function writeCache(url, manifest) {
84
+ try {
85
+ const file = cacheFilePath();
86
+ mkdirSync(dirname(file), { recursive: true });
87
+ const entry = { fetchedAt: Date.now(), url, manifest };
88
+ writeFileSync(file, JSON.stringify(entry));
89
+ }
90
+ catch {
91
+ // Best-effort. A cache write failure (read-only home dir, full disk,
92
+ // whatever) must never fail the command it is only trying to speed up
93
+ // next time.
94
+ }
95
+ }
96
+ /**
97
+ * Fetches (or reads the disk cache for) the minimums manifest. Never
98
+ * throws: any failure — timeout, network error, non-200, invalid JSON,
99
+ * schema mismatch — comes back as `{ manifest: null, source: "fail-open" }`.
100
+ */
101
+ export async function fetchManifest(options) {
102
+ const url = options?.url ?? process.env.DEVTOOLS_MINIMUMS_URL ?? DEFAULT_MINIMUMS_URL;
103
+ const timeoutMs = options?.timeoutMs ?? FETCH_TIMEOUT_MS;
104
+ if (options?.skipCache !== true) {
105
+ const cached = readCache(url);
106
+ if (cached)
107
+ return { manifest: cached, source: "cache" };
108
+ }
109
+ const controller = new AbortController();
110
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
111
+ try {
112
+ const res = await fetch(url, { signal: controller.signal });
113
+ if (!res.ok)
114
+ throw new Error(`manifest fetch: HTTP ${res.status}`);
115
+ const json = await res.json();
116
+ const parsed = ManifestSchema.safeParse(json);
117
+ if (!parsed.success) {
118
+ throw new Error(`manifest fetch: invalid shape (${parsed.error.message})`);
119
+ }
120
+ writeCache(url, parsed.data);
121
+ return { manifest: parsed.data, source: "network" };
122
+ }
123
+ catch (err) {
124
+ return { manifest: null, source: "fail-open", error: errorText(err) };
125
+ }
126
+ finally {
127
+ clearTimeout(timer);
128
+ }
129
+ }
130
+ function errorText(err) {
131
+ return err instanceof Error ? err.message : String(err);
132
+ }
133
+ /** Three-part semver comparison; `<0` means `a` is older than `b`. Anything
134
+ * non-numeric in a segment sorts as `0`, which is permissive on purpose —
135
+ * this only ever compares two well-formed manifest/package versions. */
136
+ function compareVersions(a, b) {
137
+ const pa = a.split(".").map((n) => Number.parseInt(n, 10) || 0);
138
+ const pb = b.split(".").map((n) => Number.parseInt(n, 10) || 0);
139
+ for (let i = 0; i < 3; i++) {
140
+ const diff = (pa[i] ?? 0) - (pb[i] ?? 0);
141
+ if (diff !== 0)
142
+ return diff;
143
+ }
144
+ return 0;
145
+ }
146
+ /**
147
+ * Runs the decision half of preflight. Does not itself re-exec or print —
148
+ * `runPreflight` below does both, so the pure decision stays independently
149
+ * testable (loop guard, fail-open, version comparisons) without spawning a
150
+ * real child process in every test.
151
+ */
152
+ export async function decidePreflight(options) {
153
+ if (options.skipPreflight)
154
+ return { action: "skip-preflight" };
155
+ if (options.devMode === true)
156
+ return { action: "dev-mode" };
157
+ const { manifest, source, error } = await fetchManifest({
158
+ url: options.manifestUrl,
159
+ skipCache: options.refresh,
160
+ });
161
+ if (!manifest) {
162
+ return { action: "fail-open", reason: error ?? "unknown error" };
163
+ }
164
+ const { latest, minimum } = manifest.devtools;
165
+ if (compareVersions(options.currentVersion, minimum) < 0) {
166
+ if (process.env[LOOP_GUARD_ENV]) {
167
+ return {
168
+ action: "loop-guard-tripped",
169
+ detail: `already re-exec'd once (${LOOP_GUARD_ENV}=${process.env[LOOP_GUARD_ENV]}); refusing to loop again`,
170
+ };
171
+ }
172
+ return { action: "self-refresh", from: options.currentVersion, to: latest, source };
173
+ }
174
+ if (compareVersions(options.currentVersion, latest) < 0) {
175
+ return { action: "nudge", from: options.currentVersion, to: latest, source };
176
+ }
177
+ return { action: "up-to-date", source };
178
+ }
179
+ /**
180
+ * Re-execs as the exact pinned `version`, inheriting stdio and propagating
181
+ * the child's exit code. Never returns — the caller relies on this to end
182
+ * the process, matching `spawnSync`'s synchronous nature (there is nothing
183
+ * meaningful left to do in this process either way).
184
+ *
185
+ * Exported separately from `runPreflight` so tests can stub it instead of
186
+ * spawning a real `pnpm dlx`.
187
+ */
188
+ export function selfRefresh(options) {
189
+ const spec = `@devdogsuga/devtools@${options.version}`;
190
+ const registryArgs = options.registry !== undefined ? [`--config.registry=${options.registry}`] : [];
191
+ const result = spawnSync("pnpm", ["dlx", ...registryArgs, spec, ...options.argv], {
192
+ stdio: "inherit",
193
+ env: { ...process.env, [LOOP_GUARD_ENV]: "1" },
194
+ });
195
+ process.exit(result.status ?? 1);
196
+ }
197
+ /**
198
+ * The full preflight: decides, prints the single line each outcome calls
199
+ * for (or nothing, for the common `up-to-date`/`skip-preflight`/`dev-mode`
200
+ * cases — see each branch), and re-execs on `self-refresh`. Returns once
201
+ * for every other outcome so `launch()` can continue dispatching the
202
+ * original command.
203
+ */
204
+ export async function runPreflight(options) {
205
+ const decision = await decidePreflight(options);
206
+ const reexec = options.reexec ?? selfRefresh;
207
+ switch (decision.action) {
208
+ case "skip-preflight":
209
+ case "dev-mode":
210
+ case "up-to-date":
211
+ // Silent — see the module header: this runs on every command, and a
212
+ // line of noise on the overwhelmingly common "nothing to do" path
213
+ // would defeat the point of the check running unconditionally.
214
+ break;
215
+ case "fail-open":
216
+ process.stderr.write(`devtools: preflight check skipped (${decision.reason})\n`);
217
+ break;
218
+ case "loop-guard-tripped":
219
+ process.stderr.write(`devtools: still below the minimum version after one self-refresh attempt; ` +
220
+ `continuing on the current version instead of looping (${decision.detail})\n`);
221
+ break;
222
+ case "nudge":
223
+ process.stderr.write(`devtools: a newer version (${decision.to}) is available — you're on ${decision.from}. ` +
224
+ `Run with --refresh, or it'll catch up on its own shortly.\n`);
225
+ break;
226
+ case "self-refresh":
227
+ process.stderr.write(`devtools: ${decision.from} is below the minimum supported version — ` +
228
+ `relaunching as ${decision.to}...\n`);
229
+ reexec({ version: decision.to, argv: options.argv, registry: options.registry });
230
+ break;
231
+ }
232
+ return decision;
233
+ }
234
+ let cachedOwnDir;
235
+ /** The directory this module's compiled file lives in one level under —
236
+ * i.e. the devtools package's own root (`dist/repo/preflight.js` → up two).
237
+ * Exported for tests; memoized since it never changes within a process. */
238
+ export function ownPackageDir() {
239
+ if (cachedOwnDir === undefined) {
240
+ cachedOwnDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
241
+ }
242
+ return cachedOwnDir;
243
+ }
244
+ /** Reads this build's own `package.json` — the version preflight compares
245
+ * against the manifest, and the value dev-mode detection inspects. */
246
+ export function ownVersion() {
247
+ const pkg = JSON.parse(readFileSync(join(ownPackageDir(), "package.json"), "utf8"));
248
+ return typeof pkg.version === "string" ? pkg.version : "0.0.0";
249
+ }
250
+ /**
251
+ * True when this devtools is running from a workspace/dev checkout rather
252
+ * than a published install — a `pnpm build && node ./bin/devtools.mjs` run
253
+ * inside `Backstage/packages/devtools` itself, or any other case where the
254
+ * package's own directory is not sitting under a `node_modules` tree (a
255
+ * real install — via dlx's isolated cache or an ordinary `node_modules` —
256
+ * always puts it under one). `version: "0.0.0-dev"` is the explicit,
257
+ * unambiguous override for anything that wants to force this (a fixture,
258
+ * an integration test); the path heuristic is the sensible default so
259
+ * nobody has to remember to set it.
260
+ */
261
+ export function isDevMode(version = ownVersion(), dir = ownPackageDir()) {
262
+ if (version === "0.0.0-dev")
263
+ return true;
264
+ const marker = `${sep}node_modules${sep}`;
265
+ return !dir.includes(marker);
266
+ }
@@ -0,0 +1,41 @@
1
+ export interface ResolvedFromRepo {
2
+ /** Absolute path to the resolved module entry point. */
3
+ resolvedPath: string;
4
+ /** The version declared in the resolved package's own `package.json`. */
5
+ version: string;
6
+ /** Absolute path to the resolved package's `package.json`. */
7
+ pkgJsonPath: string;
8
+ }
9
+ /**
10
+ * The installable package name a (possibly subpath) specifier belongs to:
11
+ * `@devdogsuga/env/load` → `@devdogsuga/env`, `foo/bar` → `foo`. A repo's
12
+ * package.json declares the PACKAGE as a dependency, never a subpath, so
13
+ * `findDependent` has to search on this rather than the literal specifier a
14
+ * caller wants to import.
15
+ */
16
+ export declare function packageNameOf(specifier: string): string;
17
+ /**
18
+ * Scans `apps/*` and `packages/*` under `repoRoot` for the first
19
+ * `package.json` whose `dependencies`/`devDependencies`/`peerDependencies`
20
+ * names `specifier`'s package (see `packageNameOf`). Returns its path
21
+ * relative to `repoRoot`, or `null` if nothing in the repo depends on it.
22
+ *
23
+ * Re-scans the filesystem on every call — fine for a short-lived CLI
24
+ * invocation calling this a handful of times; see FINDINGS item 5 if this
25
+ * is ever called in a hot loop.
26
+ */
27
+ export declare function findDependent(repoRoot: string, specifier: string): string | null;
28
+ /**
29
+ * Resolves `specifier` as `resolutionBase` (a workspace-relative
30
+ * `package.json` path known to depend on it, from `findDependent()`) would
31
+ * see it.
32
+ *
33
+ * `opts.condition`, when given, is NOT passed through to Node's resolver —
34
+ * `require.resolve` ignores tsx's `register({ conditions })` entirely (see
35
+ * this module's header). Instead the target package's own
36
+ * `exports["."]` map is read directly and `exports["."][condition]` is
37
+ * picked, falling back to `.default`.
38
+ */
39
+ export declare function resolveFromRepo(repoRoot: string, resolutionBase: string, specifier: string, opts?: {
40
+ condition?: string;
41
+ }): ResolvedFromRepo;
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Resolves an `@devdogsuga/*` package FROM the target repo, not from
3
+ * devtools' own (dlx-isolated) `node_modules`.
4
+ *
5
+ * Ported from the `devtools-dlx` prototype
6
+ * (`/home/sloan/scratchpad/devdogs/prototypes/devtools-dlx/FINDINGS.md`,
7
+ * experiments 2 and 4 — read that file for the two gotchas this module
8
+ * exists to avoid). The short version:
9
+ *
10
+ * - `require.resolve(\`${specifier}/package.json\`)` throws
11
+ * `ERR_PACKAGE_PATH_NOT_EXPORTED` on packages (like `@devdogsuga/env`,
12
+ * `@devdogsuga/open-graph`) whose `exports` map does not list
13
+ * `"./package.json"`, even though the file is on disk.
14
+ * - A resolved path cannot be assumed to contain a literal
15
+ * `node_modules/<specifier>` segment — pnpm workspace-linked packages
16
+ * resolve to their REALPATH (e.g. `packages/open-graph/dist/index.js`),
17
+ * which has no such segment at all.
18
+ * - `require.resolve` ignores whatever `conditions` a caller has set on
19
+ * `tsx`'s `register()` — condition-based resolution (the
20
+ * `devdogs-source` condition) has to be done by hand, reading the
21
+ * target package's own `exports["."]` map.
22
+ *
23
+ * Both problems are solved the same way: walk up from the resolved file's
24
+ * directory to the nearest `package.json` whose own `"name"` field matches
25
+ * the specifier. That works for a real installed dependency AND a
26
+ * workspace symlink, uniformly.
27
+ */
28
+ import { createRequire } from "node:module";
29
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
30
+ import { dirname, join } from "node:path";
31
+ /**
32
+ * The installable package name a (possibly subpath) specifier belongs to:
33
+ * `@devdogsuga/env/load` → `@devdogsuga/env`, `foo/bar` → `foo`. A repo's
34
+ * package.json declares the PACKAGE as a dependency, never a subpath, so
35
+ * `findDependent` has to search on this rather than the literal specifier a
36
+ * caller wants to import.
37
+ */
38
+ export function packageNameOf(specifier) {
39
+ const parts = specifier.split("/");
40
+ return specifier.startsWith("@") ? parts.slice(0, 2).join("/") : parts[0];
41
+ }
42
+ /**
43
+ * Scans `apps/*` and `packages/*` under `repoRoot` for the first
44
+ * `package.json` whose `dependencies`/`devDependencies`/`peerDependencies`
45
+ * names `specifier`'s package (see `packageNameOf`). Returns its path
46
+ * relative to `repoRoot`, or `null` if nothing in the repo depends on it.
47
+ *
48
+ * Re-scans the filesystem on every call — fine for a short-lived CLI
49
+ * invocation calling this a handful of times; see FINDINGS item 5 if this
50
+ * is ever called in a hot loop.
51
+ */
52
+ export function findDependent(repoRoot, specifier) {
53
+ const packageName = packageNameOf(specifier);
54
+ const groups = ["apps", "packages"];
55
+ for (const group of groups) {
56
+ const groupDir = join(repoRoot, group);
57
+ let entries;
58
+ try {
59
+ entries = readdirSync(groupDir);
60
+ }
61
+ catch {
62
+ continue;
63
+ }
64
+ for (const entry of entries) {
65
+ const pkgJsonPath = join(groupDir, entry, "package.json");
66
+ if (!existsSync(pkgJsonPath))
67
+ continue;
68
+ let pkg;
69
+ try {
70
+ pkg = JSON.parse(readFileSync(pkgJsonPath, "utf8"));
71
+ }
72
+ catch {
73
+ continue;
74
+ }
75
+ const deps = {
76
+ ...pkg.dependencies,
77
+ ...pkg.devDependencies,
78
+ ...pkg.peerDependencies,
79
+ };
80
+ if (packageName in deps) {
81
+ return join(group, entry, "package.json");
82
+ }
83
+ }
84
+ }
85
+ return null;
86
+ }
87
+ /** Walks up from `startDir` to the nearest `package.json` whose `"name"` matches `specifier`. */
88
+ function findOwningPackageJson(startDir, specifier) {
89
+ const packageName = packageNameOf(specifier);
90
+ let dir = startDir;
91
+ for (;;) {
92
+ const candidate = join(dir, "package.json");
93
+ if (existsSync(candidate)) {
94
+ try {
95
+ const pkg = JSON.parse(readFileSync(candidate, "utf8"));
96
+ if (pkg.name === packageName)
97
+ return candidate;
98
+ }
99
+ catch {
100
+ // fall through and keep walking
101
+ }
102
+ }
103
+ const parent = dirname(dir);
104
+ if (parent === dir) {
105
+ throw new Error(`resolveFromRepo: could not find ${specifier}'s own package.json by walking up from ${startDir}`);
106
+ }
107
+ dir = parent;
108
+ }
109
+ }
110
+ /**
111
+ * Resolves `specifier` as `resolutionBase` (a workspace-relative
112
+ * `package.json` path known to depend on it, from `findDependent()`) would
113
+ * see it.
114
+ *
115
+ * `opts.condition`, when given, is NOT passed through to Node's resolver —
116
+ * `require.resolve` ignores tsx's `register({ conditions })` entirely (see
117
+ * this module's header). Instead the target package's own
118
+ * `exports["."]` map is read directly and `exports["."][condition]` is
119
+ * picked, falling back to `.default`.
120
+ */
121
+ export function resolveFromRepo(repoRoot, resolutionBase, specifier, opts) {
122
+ const baseFile = join(repoRoot, resolutionBase);
123
+ const require = createRequire(baseFile);
124
+ let resolvedPath;
125
+ if (opts?.condition) {
126
+ // Find the package's own package.json first (via a plain resolve of the
127
+ // specifier itself, which DOES work through node's default conditions),
128
+ // then read its exports map by hand.
129
+ const defaultEntry = require.resolve(specifier);
130
+ const pkgJsonPath = findOwningPackageJson(dirname(defaultEntry), specifier);
131
+ const pkg = JSON.parse(readFileSync(pkgJsonPath, "utf8"));
132
+ const rootExport = pkg.exports?.["."];
133
+ const conditioned = rootExport && typeof rootExport === "object"
134
+ ? (rootExport[opts.condition] ??
135
+ rootExport.default)
136
+ : undefined;
137
+ if (typeof conditioned !== "string") {
138
+ throw new Error(`resolveFromRepo: ${specifier} has no "${opts.condition}" (or "default") export in its exports["."] map`);
139
+ }
140
+ resolvedPath = join(dirname(pkgJsonPath), conditioned);
141
+ }
142
+ else {
143
+ resolvedPath = require.resolve(specifier);
144
+ }
145
+ const pkgJsonPath = findOwningPackageJson(dirname(resolvedPath), specifier);
146
+ const pkg = JSON.parse(readFileSync(pkgJsonPath, "utf8"));
147
+ return {
148
+ resolvedPath,
149
+ version: pkg.version ?? "0.0.0",
150
+ pkgJsonPath,
151
+ };
152
+ }