@astryxdesign/cli 0.1.7 → 0.1.8-canary.0c4d8d7

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 (134) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +115 -19
  3. package/docs/cli-integrations.doc.mjs +150 -0
  4. package/docs/elevation.doc.mjs +79 -1
  5. package/docs/getting-started.doc.mjs +9 -9
  6. package/docs/migration.doc.mjs +18 -18
  7. package/docs/principles.doc.dense.mjs +1 -1
  8. package/docs/principles.doc.mjs +6 -6
  9. package/docs/principles.doc.zh.mjs +1 -1
  10. package/docs/styling-libraries.doc.mjs +3 -3
  11. package/docs/styling.doc.mjs +4 -4
  12. package/docs/theme.doc.dense.mjs +2 -2
  13. package/docs/theme.doc.mjs +7 -7
  14. package/docs/theme.doc.zh.mjs +1 -1
  15. package/docs/tokens.doc.mjs +1 -1
  16. package/docs/working-with-ai.doc.mjs +19 -19
  17. package/package.json +13 -11
  18. package/scripts/postinstall.mjs +74 -0
  19. package/src/api/doctor.mjs +3 -3
  20. package/src/api/error.mjs +2 -2
  21. package/src/codemods/__tests__/registry.test.mjs +1 -0
  22. package/src/codemods/ensure-jscodeshift.mjs +11 -27
  23. package/src/codemods/registry.mjs +1 -0
  24. package/src/codemods/run-codemod.mjs +1 -1
  25. package/src/codemods/runner.mjs +2 -2
  26. package/src/codemods/transforms/v0.1.0/__tests__/drop-xds-prefix-imports.test.mjs +42 -7
  27. package/src/codemods/transforms/v0.1.0/__tests__/migrate-xds-module-specifiers.test.mjs +43 -0
  28. package/src/codemods/transforms/v0.1.0/drop-xds-prefix-imports.mjs +102 -3
  29. package/src/codemods/transforms/v0.1.0/migrate-xds-module-specifiers.mjs +72 -6
  30. package/src/codemods/transforms/v0.1.8/__tests__/rename-avatar-size-scale.test.mjs +161 -0
  31. package/src/codemods/transforms/v0.1.8/index.mjs +19 -0
  32. package/src/codemods/transforms/v0.1.8/rename-avatar-size-scale.mjs +234 -0
  33. package/src/commands/agent-docs.mjs +136 -20
  34. package/src/commands/agent-docs.test.mjs +123 -10
  35. package/src/commands/build-theme.mjs +10 -71
  36. package/src/commands/build.mjs +15 -15
  37. package/src/commands/cli-postinstall.test.mjs +42 -0
  38. package/src/commands/component/index.mjs +4 -4
  39. package/src/commands/discover.mjs +7 -5
  40. package/src/commands/docs.mjs +4 -4
  41. package/src/commands/hook/index.mjs +4 -4
  42. package/src/commands/init.mjs +48 -152
  43. package/src/commands/init.next-steps.test.mjs +1 -1
  44. package/src/commands/interactive-guard.test.mjs +19 -22
  45. package/src/commands/json-contract.test.mjs +2 -2
  46. package/src/commands/layout.mjs +1 -1
  47. package/src/commands/search.mjs +4 -4
  48. package/src/commands/setup-nudge.test.mjs +108 -0
  49. package/src/commands/swizzle.mjs +12 -35
  50. package/src/commands/template.mjs +11 -31
  51. package/src/commands/upgrade.mjs +138 -32
  52. package/src/commands/upgrade.test.mjs +155 -1
  53. package/src/index.mjs +40 -6
  54. package/src/lib/cli-error.mjs +2 -2
  55. package/src/lib/component-format.mjs +2 -1
  56. package/src/lib/json-shim.mjs +1 -1
  57. package/src/lib/json.mjs +3 -3
  58. package/src/lib/term-log.mjs +48 -0
  59. package/src/types/api.d.ts +4 -9
  60. package/src/types/base.d.ts +24 -3
  61. package/src/types/build.d.ts +23 -0
  62. package/src/types/index.d.ts +1 -0
  63. package/src/types/swizzle.d.ts +4 -0
  64. package/src/types/template-api.d.ts +4 -1
  65. package/src/types/upgrade.d.ts +39 -2
  66. package/src/utils/package-manager.mjs +108 -4
  67. package/src/utils/package-manager.test.mjs +108 -1
  68. package/src/utils/path-safety.mjs +0 -18
  69. package/src/utils/update-check.mjs +5 -4
  70. package/src/utils/update-check.test.mjs +3 -3
  71. package/templates/blocks/components/Avatar/AvatarFallbackChain.tsx +4 -4
  72. package/templates/blocks/components/Avatar/AvatarGroup.tsx +2 -2
  73. package/templates/blocks/components/Avatar/AvatarInitialsFallback.tsx +1 -1
  74. package/templates/blocks/components/Avatar/AvatarShowcase.tsx +4 -4
  75. package/templates/blocks/components/Avatar/AvatarUserCard.tsx +1 -1
  76. package/templates/blocks/components/Avatar/AvatarWithImage.tsx +4 -4
  77. package/templates/blocks/components/Avatar/AvatarWithStatus.tsx +3 -3
  78. package/templates/blocks/components/AvatarGroup/AvatarGroupShowcase.tsx +2 -2
  79. package/templates/blocks/components/AvatarGroupOverflow/AvatarGroupOverflowCustomText.tsx +1 -1
  80. package/templates/blocks/components/AvatarGroupOverflow/AvatarGroupOverflowDefault.tsx +1 -1
  81. package/templates/blocks/components/AvatarGroupOverflow/AvatarGroupOverflowShowcase.tsx +2 -2
  82. package/templates/blocks/components/AvatarStatusDot/AvatarStatusDotShowcase.tsx +3 -3
  83. package/templates/blocks/components/AvatarStatusDot/AvatarStatusDotVariants.tsx +3 -3
  84. package/templates/blocks/components/Banner/BannerFloating.doc.mjs +14 -0
  85. package/templates/blocks/components/Banner/BannerFloating.tsx +16 -0
  86. package/templates/blocks/components/Button/ButtonFloating.doc.mjs +14 -0
  87. package/templates/blocks/components/Button/ButtonFloating.tsx +37 -0
  88. package/templates/blocks/components/ButtonGroup/ButtonGroupFloating.doc.mjs +14 -0
  89. package/templates/blocks/components/ButtonGroup/ButtonGroupFloating.tsx +23 -0
  90. package/templates/blocks/components/Card/CardElevations.doc.mjs +14 -0
  91. package/templates/blocks/components/Card/CardElevations.tsx +32 -0
  92. package/templates/blocks/components/Card/ClickableCardElevated.doc.mjs +14 -0
  93. package/templates/blocks/components/Card/ClickableCardElevated.tsx +21 -0
  94. package/templates/blocks/components/Card/SelectableCardElevated.doc.mjs +14 -0
  95. package/templates/blocks/components/Card/SelectableCardElevated.tsx +37 -0
  96. package/templates/blocks/components/Carousel/CarouselSnap.tsx +1 -1
  97. package/templates/blocks/components/ChatComposer/ChatComposerFlat.doc.mjs +14 -0
  98. package/templates/blocks/components/ChatComposer/ChatComposerFlat.tsx +75 -0
  99. package/templates/blocks/components/ChatMessage/ChatMessageAvatarName.tsx +2 -2
  100. package/templates/blocks/components/ChatMessage/ChatMessageMultiBubble.tsx +1 -1
  101. package/templates/blocks/components/ChatMessageBubble/ChatMessageBubbleGrouping.tsx +1 -1
  102. package/templates/blocks/components/ChatMessageBubble/ChatMessageBubbleMetadata.tsx +1 -1
  103. package/templates/blocks/components/ChatMessageList/ChatMessageListDensity.tsx +5 -9
  104. package/templates/blocks/components/ChatMessageList/ChatMessageListFullFeatured.tsx +1 -1
  105. package/templates/blocks/components/CodeBlock/CodeBlockTerminal.tsx +1 -1
  106. package/templates/blocks/components/HoverCard/HoverCardShowcase.tsx +1 -1
  107. package/templates/blocks/components/IconButton/IconButtonFloating.doc.mjs +14 -0
  108. package/templates/blocks/components/IconButton/IconButtonFloating.tsx +37 -0
  109. package/templates/blocks/components/Item/ItemWithMedia.tsx +2 -2
  110. package/templates/blocks/components/ListItem/ListItemWithMedia.tsx +2 -2
  111. package/templates/blocks/components/OverflowList/OverflowListCappedToolbar.doc.mjs +14 -0
  112. package/templates/blocks/components/OverflowList/OverflowListCappedToolbar.tsx +39 -0
  113. package/templates/blocks/components/OverflowList/OverflowListMultiRowTags.doc.mjs +14 -0
  114. package/templates/blocks/components/OverflowList/OverflowListMultiRowTags.tsx +44 -0
  115. package/templates/blocks/components/Pagination/PaginationDotsCarousel.tsx +2 -6
  116. package/templates/blocks/components/Stack/StackFillItem.tsx +2 -6
  117. package/templates/blocks/components/TabList/TabListTabsWithActions.doc.mjs +1 -1
  118. package/templates/blocks/components/TabList/TabListTabsWithActions.tsx +2 -7
  119. package/templates/blocks/components/Table/TableRowStatusTable.doc.mjs +14 -0
  120. package/templates/blocks/components/Table/TableRowStatusTable.tsx +59 -0
  121. package/templates/blocks/components/Thumbnail/ThumbnailElevated.doc.mjs +14 -0
  122. package/templates/blocks/components/Thumbnail/ThumbnailElevated.tsx +31 -0
  123. package/templates/blocks/components/TypeaheadItem/TypeaheadItemShowcase.tsx +1 -1
  124. package/templates/pages/ai-chat/page.tsx +4 -4
  125. package/templates/pages/dashboard-portfolio/page.tsx +3 -11
  126. package/templates/pages/detail-page/page.tsx +5 -12
  127. package/templates/pages/documentation-design/page.tsx +1 -1
  128. package/templates/pages/messaging-shell/page.tsx +6 -8
  129. package/templates/pages/table-grouped/page.tsx +9 -14
  130. package/templates/pages/table-page/page.tsx +7 -9
  131. package/templates/pages/table-page-heatmap-status/page.tsx +5 -13
  132. package/docs/integration-authoring.md +0 -105
  133. package/src/utils/interactive.mjs +0 -76
  134. package/src/utils/interactive.test.mjs +0 -70
package/src/lib/json.mjs CHANGED
@@ -112,10 +112,10 @@ export function jsonOut(type, data, meta) {
112
112
  * consumers can branch on it unconditionally.
113
113
  *
114
114
  * @param {unknown} err
115
- * @param {Array<{name: string, reason: string}>} [suggestions]
115
+ * @param {import('../types/base').Suggestion[]} [suggestions]
116
116
  * @param {string} [code] - Explicit stable error code. Overrides any code
117
117
  * carried on a thrown Error.
118
- * @returns {{apiVersion: number, error: string, code: string, suggestions?: Array<{name: string, reason: string}>}}
118
+ * @returns {{apiVersion: number, error: string, code: string, suggestions?: import('../types/base').Suggestion[]}}
119
119
  */
120
120
  export function toErrorEnvelope(err, suggestions, code) {
121
121
  const message =
@@ -136,7 +136,7 @@ export function toErrorEnvelope(err, suggestions, code) {
136
136
  /**
137
137
  * Output a structured JSON error and exit.
138
138
  * @param {string} message
139
- * @param {Array<{name: string, reason: string}>} [suggestions]
139
+ * @param {import('../types/base').Suggestion[]} [suggestions]
140
140
  * @param {string} [code] - Stable machine-readable error code (error-codes.mjs).
141
141
  */
142
142
  export function jsonError(message, suggestions, code) {
@@ -0,0 +1,48 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file Minimal non-interactive terminal logger.
5
+ *
6
+ * @input message strings from CLI commands/codemods
7
+ * @output plain lines on stdout via humanLog (suppressed in --json mode)
8
+ * @position src/lib — shared output helper, no side effects on import
9
+ *
10
+ * The CLI is fully non-interactive: it never prompts, so it only needs plain,
11
+ * unbuffered output. This provides the *output-only* surface (`log.*`, `intro`,
12
+ * `outro`) the CLI needs, so it has no dependency on any prompt library.
13
+ *
14
+ * All output is routed through `humanLog`, the CLI's stdout-discipline
15
+ * primitive, which is a no-op in `--json` mode — so these human logs can never
16
+ * corrupt a JSON envelope.
17
+ *
18
+ * Call sites use it as `import * as p from '../lib/term-log.mjs'` and call
19
+ * `p.log.info(...)`, `p.intro(...)`, `p.outro(...)`.
20
+ */
21
+
22
+ import {humanLog} from './json.mjs';
23
+
24
+ const toStr = (msg) => (msg === undefined || msg === null ? '' : String(msg));
25
+
26
+ /**
27
+ * Human-facing log surface (the small `log` API the CLI uses). All lines go to
28
+ * stdout via humanLog; the level prefixes are cosmetic. `--json` mode suppresses
29
+ * every one of these, keeping machine-readable stdout clean.
30
+ */
31
+ export const log = {
32
+ message: (msg) => humanLog(toStr(msg)),
33
+ info: (msg) => humanLog(toStr(msg)),
34
+ step: (msg) => humanLog(toStr(msg)),
35
+ success: (msg) => humanLog(`✓ ${toStr(msg)}`),
36
+ warn: (msg) => humanLog(`⚠ ${toStr(msg)}`),
37
+ error: (msg) => humanLog(`✗ ${toStr(msg)}`),
38
+ };
39
+
40
+ /** Banner printed at the start of a multi-step command. */
41
+ export function intro(title) {
42
+ humanLog(`\n${toStr(title)}`);
43
+ }
44
+
45
+ /** Footer printed at the end of a multi-step command. */
46
+ export function outro(message) {
47
+ humanLog(`${toStr(message)}\n`);
48
+ }
@@ -44,17 +44,14 @@ import type {
44
44
  import type {SearchResponse, SearchDomain} from './search';
45
45
  import type {ErrorCode} from './error-codes';
46
46
  import type {DoctorResponse} from './doctor';
47
+ import type {Suggestion} from './base';
47
48
 
48
49
  /** Structured API error with a stable machine-readable code. */
49
50
  export declare class AstryxError extends Error {
50
51
  /** Stable error code; consumers branch on this, never the message. */
51
52
  code: ErrorCode;
52
- suggestions?: Array<{name: string; reason: string}>;
53
- constructor(
54
- message: string,
55
- suggestions?: Array<{name: string; reason: string}>,
56
- code?: ErrorCode,
57
- );
53
+ suggestions?: Suggestion[];
54
+ constructor(message: string, suggestions?: Suggestion[], code?: ErrorCode);
58
55
  }
59
56
 
60
57
  // ── Component ────────────────────────────────────────────────────────
@@ -100,9 +97,7 @@ export interface DocsOptions {
100
97
  }
101
98
 
102
99
  type DocsResult =
103
- | DocsListResponse
104
- | DocsDetailResponse
105
- | DocsDetailSectionResponse;
100
+ DocsListResponse | DocsDetailResponse | DocsDetailSectionResponse;
106
101
 
107
102
  export declare function docs(
108
103
  topic?: string,
@@ -11,10 +11,12 @@
11
11
  import type {
12
12
  ComponentListResponse,
13
13
  ComponentBriefResponse,
14
+ ComponentFullResponse,
14
15
  ComponentDetailResponse,
15
16
  ComponentDetailPropsResponse,
16
17
  ComponentDetailSourceResponse,
17
18
  ComponentDetailShowcaseResponse,
19
+ ComponentDetailBlocksResponse,
18
20
  } from './component';
19
21
  import type {
20
22
  DiscoverListResponse,
@@ -42,13 +44,28 @@ import type {
42
44
  } from './hook';
43
45
  import type {SwizzleListResponse, SwizzleCopyResponse} from './swizzle';
44
46
  import type {ThemeBuildResponse} from './theme';
45
- import type {UpgradeListResponse, UpgradeRunResponse} from './upgrade';
47
+ import type {
48
+ UpgradeListResponse,
49
+ UpgradeRunResponse,
50
+ UpgradeStatusResponse,
51
+ } from './upgrade';
46
52
  import type {SearchResponse} from './search';
53
+ import type {BuildHelpResponse} from './build';
47
54
  import type {ErrorCode} from './error-codes';
48
55
  import type {ManifestResponse} from './manifest';
49
56
  import type {DoctorResponse} from './doctor';
50
57
  import type {ValidateIntegrationResponse} from './validate-integration';
51
58
 
59
+ /**
60
+ * A "did you mean…" suggestion attached to an error. `reason` is optional:
61
+ * some call sites emit bare `{name}` (e.g. a list of candidate component names)
62
+ * with no per-item explanation.
63
+ */
64
+ export interface Suggestion {
65
+ name: string;
66
+ reason?: string;
67
+ }
68
+
52
69
  /**
53
70
  * Structured error. Check `'error' in result` to discriminate.
54
71
  *
@@ -58,7 +75,7 @@ import type {ValidateIntegrationResponse} from './validate-integration';
58
75
  export interface CLIError {
59
76
  error: string;
60
77
  code: ErrorCode;
61
- suggestions?: Array<{name: string; reason: string}>;
78
+ suggestions?: Suggestion[];
62
79
  }
63
80
 
64
81
  /** Returned by the fallback hook for commands without --json support. */
@@ -74,10 +91,12 @@ export type CLIResult<T> = T | CLIError | CLIUnsupportedError;
74
91
  export type CLIAnyResponse =
75
92
  | ComponentListResponse
76
93
  | ComponentBriefResponse
94
+ | ComponentFullResponse
77
95
  | ComponentDetailResponse
78
96
  | ComponentDetailPropsResponse
79
97
  | ComponentDetailSourceResponse
80
98
  | ComponentDetailShowcaseResponse
99
+ | ComponentDetailBlocksResponse
81
100
  | DiscoverListResponse
82
101
  | DiscoverDetailResponse
83
102
  | DiscoverDetailDocResponse
@@ -99,7 +118,9 @@ export type CLIAnyResponse =
99
118
  | ThemeBuildResponse
100
119
  | UpgradeListResponse
101
120
  | UpgradeRunResponse
121
+ | UpgradeStatusResponse
102
122
  | SearchResponse
123
+ | BuildHelpResponse
103
124
  | ManifestResponse
104
125
  | DoctorResponse
105
126
  | ValidateIntegrationResponse;
@@ -129,7 +150,7 @@ export function jsonOut<T extends CLIResponseType>(
129
150
  */
130
151
  export function jsonError(
131
152
  message: string,
132
- suggestions?: Array<{name: string; reason: string}>,
153
+ suggestions?: Suggestion[],
133
154
  code?: ErrorCode,
134
155
  ): never;
135
156
 
@@ -0,0 +1,23 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * Build command JSON responses.
5
+ *
6
+ * `astryx build` is the "assemble a page" verb. With no query it emits the
7
+ * workflow playbook; with a query it delegates to the search pipeline and
8
+ * emits a `search` response (see search.d.ts).
9
+ *
10
+ * Invocation -> type discriminator
11
+ * ---------------------------------------------------
12
+ * xds --json build -> build.help
13
+ * xds --json build "<idea>" -> search
14
+ */
15
+
16
+ /** xds --json build (no query) — the "how to build a page" playbook signal. */
17
+ export interface BuildHelpResponse {
18
+ type: 'build.help';
19
+ data: {
20
+ /** Always true; marks this envelope as the playbook rather than a result set. */
21
+ playbook: true;
22
+ };
23
+ }
@@ -10,6 +10,7 @@ export * from './swizzle';
10
10
  export * from './theme';
11
11
  export * from './upgrade';
12
12
  export * from './search';
13
+ export * from './build';
13
14
  export * from './error-codes';
14
15
  export * from './manifest';
15
16
  export * from './doctor';
@@ -29,9 +29,13 @@ export interface SwizzleCopyResponse {
29
29
  type: 'swizzle.copy';
30
30
  data: {
31
31
  component: string;
32
+ /** Owner package the component source was copied from. */
33
+ package: string;
32
34
  outputDir: string;
33
35
  filesCopied: number;
34
36
  files: string[];
37
+ /** Whether any copied file uses StyleX (requires build-time setup). */
38
+ usesStyleX: boolean;
35
39
  feedback?: SwizzleFeedback;
36
40
  };
37
41
  }
@@ -15,4 +15,7 @@ export type {
15
15
  AstryxTemplate,
16
16
  } from '@astryxdesign/core/authoring';
17
17
 
18
- export {createPageTemplate, createBlockTemplate} from '@astryxdesign/core/authoring';
18
+ export {
19
+ createPageTemplate,
20
+ createBlockTemplate,
21
+ } from '@astryxdesign/core/authoring';
@@ -23,6 +23,31 @@ export interface UpgradeListEntry {
23
23
  version: string;
24
24
  }
25
25
 
26
+ /**
27
+ * State of the managed agent-docs block (`<!-- ASTRYX:START --> … END -->`)
28
+ * relative to the installed core version, plus what `upgrade` did about it.
29
+ * Present on every upgrade response (run, status, and the codemod/config error
30
+ * envelopes) because the block is refreshed independently of codemods.
31
+ *
32
+ * - `refreshed` — a stale block was rewritten (`--apply` only).
33
+ * - `would-refresh` — a stale block was detected in dry-run; nothing written.
34
+ * - `nudge-init` — no managed block exists; user should run `init`.
35
+ * - `error` — refresh was attempted but writing failed.
36
+ * - `none` — nothing to do (block already current).
37
+ */
38
+ export interface AgentDocsSummary {
39
+ status: 'missing' | 'stale' | 'current';
40
+ /** Installed core version the block should reflect. */
41
+ installedVersion: string;
42
+ /** Distinct stale block versions found (the "from" side of the refresh). */
43
+ fromVersions: string[];
44
+ /** Files rewritten (apply) or that would be rewritten (dry-run). */
45
+ files: string[];
46
+ /** True only when a block was actually rewritten (apply mode). */
47
+ refreshed: boolean;
48
+ action: 'refreshed' | 'would-refresh' | 'nudge-init' | 'error' | 'none';
49
+ }
50
+
26
51
  /** xds --json upgrade [--apply] */
27
52
  export interface UpgradeRunResponse {
28
53
  type: 'upgrade.run';
@@ -32,6 +57,7 @@ export interface UpgradeRunResponse {
32
57
  codemods: number;
33
58
  depsUpdated: string[];
34
59
  agentDocsRefreshed: boolean;
60
+ agentDocs: AgentDocsSummary;
35
61
  };
36
62
  }
37
63
 
@@ -49,8 +75,18 @@ export interface UpgradeRunResponse {
49
75
  export interface UpgradeStatusResponse {
50
76
  type: 'upgrade.status';
51
77
  data:
52
- | {status: 'up_to_date'; from: string; to: string}
53
- | {status: 'no_codemods'; from: string; to: string}
78
+ | {
79
+ status: 'up_to_date';
80
+ from: string;
81
+ to: string;
82
+ agentDocs: AgentDocsSummary;
83
+ }
84
+ | {
85
+ status: 'no_codemods';
86
+ from: string;
87
+ to: string;
88
+ agentDocs: AgentDocsSummary;
89
+ }
54
90
  | {
55
91
  status: 'config_fixable';
56
92
  from: string;
@@ -60,5 +96,6 @@ export interface UpgradeStatusResponse {
60
96
  suggestedCommand: string;
61
97
  message: string;
62
98
  note: string;
99
+ agentDocs: AgentDocsSummary;
63
100
  };
64
101
  }
@@ -10,15 +10,41 @@
10
10
  import * as fs from 'node:fs';
11
11
  import * as path from 'node:path';
12
12
 
13
+ /**
14
+ * A package manager we can install with and run binaries through.
15
+ * @typedef {'yarn' | 'pnpm' | 'bun' | 'npm'} PackageManager
16
+ */
17
+
18
+ /**
19
+ * Result of package-manager detection. `'npx'` is the sentinel for "nothing
20
+ * detected" — no lockfile, no `packageManager` field, no runner user-agent —
21
+ * so callers fall back to npm/npx. It is a distinct value from {@link PackageManager}
22
+ * because it means "undetected", not "npm was chosen".
23
+ * @typedef {PackageManager | 'npx'} DetectedPackageManager
24
+ */
25
+
26
+ /** @type {readonly PackageManager[]} */
27
+ const KNOWN_PMS = ['yarn', 'pnpm', 'bun', 'npm'];
28
+
29
+ /**
30
+ * Narrow an arbitrary string to a known {@link PackageManager}.
31
+ * @param {string} name
32
+ * @returns {name is PackageManager}
33
+ */
34
+ function isKnownPackageManager(name) {
35
+ return /** @type {readonly string[]} */ (KNOWN_PMS).includes(name);
36
+ }
37
+
13
38
  /**
14
39
  * Detect the package manager used in a project directory.
15
40
  * Walks up from targetDir looking for lockfiles.
16
41
  *
42
+ * Returns `'npx'` when nothing can be detected — see {@link DetectedPackageManager}.
43
+ *
17
44
  * @param {string} [targetDir=process.cwd()]
18
- * @returns {'yarn'|'pnpm'|'bun'|'npm'}
45
+ * @returns {DetectedPackageManager}
19
46
  */
20
47
  export function detectPackageManager(targetDir = process.cwd()) {
21
- const KNOWN_PMS = new Set(['yarn', 'pnpm', 'bun', 'npm']);
22
48
  let dir = path.resolve(targetDir);
23
49
  const root = path.parse(dir).root;
24
50
 
@@ -36,7 +62,7 @@ export function detectPackageManager(targetDir = process.cwd()) {
36
62
  const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
37
63
  if (pkg.packageManager) {
38
64
  const name = pkg.packageManager.split('@')[0];
39
- if (KNOWN_PMS.has(name)) return name;
65
+ if (isKnownPackageManager(name)) return name;
40
66
  }
41
67
  } catch {
42
68
  // Best-effort: unreadable/invalid package.json — keep walking up.
@@ -50,7 +76,7 @@ export function detectPackageManager(targetDir = process.cwd()) {
50
76
  const ua = process.env.npm_config_user_agent;
51
77
  if (ua) {
52
78
  const name = ua.split('/')[0];
53
- if (KNOWN_PMS.has(name)) return name;
79
+ if (isKnownPackageManager(name)) return name;
54
80
  }
55
81
 
56
82
  return 'npx';
@@ -72,3 +98,81 @@ export function getRunPrefix(targetDir) {
72
98
  default: return 'npx';
73
99
  }
74
100
  }
101
+
102
+ /** The published CLI package name — used for one-off (uninstalled) invocations. */
103
+ export const CLI_PACKAGE = '@astryxdesign/cli';
104
+
105
+ /** The CLI binary name — only resolves once the CLI is installed (or run via CLI_PACKAGE). */
106
+ export const CLI_BIN = 'astryx';
107
+
108
+ /**
109
+ * Get the one-off ("dlx") runner for the detected package manager.
110
+ *
111
+ * Unlike {@link getRunPrefix} (which runs an *installed* binary), this fetches
112
+ * and runs a package on demand — so it is always paired with the scoped
113
+ * {@link CLI_PACKAGE}, never the bare `astryx` bin. Running bare `npx astryx`
114
+ * without the CLI installed resolves to an unrelated package on the registry.
115
+ *
116
+ * @param {string} [targetDir]
117
+ * @returns {string} e.g. 'npx', 'pnpm dlx', 'yarn dlx', 'bunx'
118
+ */
119
+ export function getDlxPrefix(targetDir) {
120
+ const pm = detectPackageManager(targetDir);
121
+ switch (pm) {
122
+ case 'yarn': return 'yarn dlx';
123
+ case 'pnpm': return 'pnpm dlx';
124
+ case 'bun': return 'bunx';
125
+ case 'npm':
126
+ default: return 'npx';
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Heuristic: was the running CLI launched one-off via a package runner
132
+ * (npx / pnpm dlx / yarn dlx / bunx) rather than from an installed dependency?
133
+ *
134
+ * We sniff the entry path (`process.argv[1]`) for well-known runner-cache
135
+ * markers. This errs safe in both directions: a false negative falls back to
136
+ * the installed form (`<prefix> astryx`, the historical behavior), and a false
137
+ * positive emits the always-valid scoped form (`<dlx> @astryxdesign/cli`).
138
+ *
139
+ * @returns {boolean}
140
+ */
141
+ export function isCliOneOff() {
142
+ const entry = String(process.argv[1] || '').replace(/\\/g, '/');
143
+ return /\/_npx\/|\/dlx[-/]|\/\.bun\/install\/cache\/|\/bunx-/.test(entry);
144
+ }
145
+
146
+ /**
147
+ * The safe, install-aware CLI invocation stem to suggest to users.
148
+ *
149
+ * - Installed / global / dev: `<run-prefix> astryx` (e.g. `pnpm exec astryx`).
150
+ * Bare `astryx` resolves to the local (or global) binary.
151
+ * - One-off (npx/dlx cache): `<dlx-prefix> @astryxdesign/cli` — the bare
152
+ * `astryx` name isn't on disk, so npm would fetch an unrelated registry
153
+ * package; the scoped package always resolves to us.
154
+ *
155
+ * @param {string} [targetDir]
156
+ * @returns {string}
157
+ */
158
+ export function getCliInvocation(targetDir) {
159
+ if (isCliOneOff()) return `${getDlxPrefix(targetDir)} ${CLI_PACKAGE}`;
160
+ return `${getRunPrefix(targetDir)} ${CLI_BIN}`;
161
+ }
162
+
163
+ /**
164
+ * Format a full, runnable CLI command from a subcommand string.
165
+ *
166
+ * Accepts either `astryx component Button` or `component Button` (a leading
167
+ * `astryx` token is stripped) and prepends the install-aware invocation stem
168
+ * from {@link getCliInvocation}.
169
+ *
170
+ * @param {string} command e.g. 'astryx component Button' | 'docs tokens'
171
+ * @param {string} [targetDir]
172
+ * @returns {string}
173
+ */
174
+ export function formatCliCommand(command, targetDir) {
175
+ const sub = String(command).replace(/^\s*astryx\b\s*/, '').trim();
176
+ const stem = getCliInvocation(targetDir);
177
+ return sub ? `${stem} ${sub}` : stem;
178
+ }
@@ -4,9 +4,16 @@ import {describe, it, expect, afterEach, vi} from 'vitest';
4
4
  import * as fs from 'node:fs';
5
5
  import * as path from 'node:path';
6
6
  import * as os from 'node:os';
7
- import {detectPackageManager} from './package-manager.mjs';
7
+ import {
8
+ detectPackageManager,
9
+ getDlxPrefix,
10
+ isCliOneOff,
11
+ getCliInvocation,
12
+ formatCliCommand,
13
+ } from './package-manager.mjs';
8
14
 
9
15
  let tmpDir;
16
+ const ORIGINAL_ARGV1 = process.argv[1];
10
17
 
11
18
  afterEach(() => {
12
19
  if (tmpDir) {
@@ -15,6 +22,7 @@ afterEach(() => {
15
22
  }
16
23
  vi.restoreAllMocks();
17
24
  delete process.env.npm_config_user_agent;
25
+ process.argv[1] = ORIGINAL_ARGV1;
18
26
  });
19
27
 
20
28
  function makeTmpDir() {
@@ -111,3 +119,102 @@ describe('detectPackageManager', () => {
111
119
  expect(detectPackageManager(dir)).toBe('bun');
112
120
  });
113
121
  });
122
+
123
+ describe('getDlxPrefix', () => {
124
+ it('returns "pnpm dlx" for pnpm projects', () => {
125
+ const dir = makeTmpDir();
126
+ fs.writeFileSync(path.join(dir, 'pnpm-lock.yaml'), '');
127
+ expect(getDlxPrefix(dir)).toBe('pnpm dlx');
128
+ });
129
+
130
+ it('returns "yarn dlx" for yarn projects', () => {
131
+ const dir = makeTmpDir();
132
+ fs.writeFileSync(path.join(dir, 'yarn.lock'), '');
133
+ expect(getDlxPrefix(dir)).toBe('yarn dlx');
134
+ });
135
+
136
+ it('returns "bunx" for bun projects', () => {
137
+ const dir = makeTmpDir();
138
+ fs.writeFileSync(path.join(dir, 'bun.lockb'), '');
139
+ expect(getDlxPrefix(dir)).toBe('bunx');
140
+ });
141
+
142
+ it('falls back to "npx" with no signals', () => {
143
+ const dir = makeTmpDir();
144
+ delete process.env.npm_config_user_agent;
145
+ expect(getDlxPrefix(dir)).toBe('npx');
146
+ });
147
+ });
148
+
149
+ describe('isCliOneOff', () => {
150
+ it('detects an npm npx cache entry', () => {
151
+ process.argv[1] = '/home/u/.npm/_npx/a1b2/node_modules/.bin/astryx';
152
+ expect(isCliOneOff()).toBe(true);
153
+ });
154
+
155
+ it('detects a pnpm dlx cache entry', () => {
156
+ process.argv[1] = '/home/u/.cache/pnpm/dlx/9f/node_modules/@astryxdesign/cli/bin/astryx.mjs';
157
+ expect(isCliOneOff()).toBe(true);
158
+ });
159
+
160
+ it('detects a bunx cache entry', () => {
161
+ process.argv[1] = '/home/u/.bun/install/cache/@astryxdesign/cli/bin/astryx.mjs';
162
+ expect(isCliOneOff()).toBe(true);
163
+ });
164
+
165
+ it('is false for an installed node_modules entry', () => {
166
+ process.argv[1] = '/proj/node_modules/@astryxdesign/cli/bin/astryx.mjs';
167
+ expect(isCliOneOff()).toBe(false);
168
+ });
169
+
170
+ it('is false for a source checkout (dev) entry', () => {
171
+ process.argv[1] = '/repo/packages/cli/bin/astryx.mjs';
172
+ expect(isCliOneOff()).toBe(false);
173
+ });
174
+ });
175
+
176
+ describe('getCliInvocation', () => {
177
+ it('uses the run-prefix + bare bin when installed (not one-off)', () => {
178
+ process.argv[1] = '/proj/node_modules/@astryxdesign/cli/bin/astryx.mjs';
179
+ const dir = makeTmpDir();
180
+ fs.writeFileSync(path.join(dir, 'pnpm-lock.yaml'), '');
181
+ expect(getCliInvocation(dir)).toBe('pnpm exec astryx');
182
+ });
183
+
184
+ it('uses the dlx runner + scoped package when run one-off', () => {
185
+ process.argv[1] = '/home/u/.npm/_npx/a1b2/node_modules/.bin/astryx';
186
+ const dir = makeTmpDir();
187
+ delete process.env.npm_config_user_agent;
188
+ expect(getCliInvocation(dir)).toBe('npx @astryxdesign/cli');
189
+ });
190
+
191
+ it('pairs the dlx runner with the scoped package for pnpm one-off', () => {
192
+ process.argv[1] = '/home/u/.cache/pnpm/dlx/9f/node_modules/@astryxdesign/cli/bin/astryx.mjs';
193
+ const dir = makeTmpDir();
194
+ fs.writeFileSync(path.join(dir, 'pnpm-lock.yaml'), '');
195
+ expect(getCliInvocation(dir)).toBe('pnpm dlx @astryxdesign/cli');
196
+ });
197
+ });
198
+
199
+ describe('formatCliCommand', () => {
200
+ it('strips a leading "astryx" token and prepends the invocation stem', () => {
201
+ process.argv[1] = '/proj/node_modules/@astryxdesign/cli/bin/astryx.mjs';
202
+ const dir = makeTmpDir();
203
+ fs.writeFileSync(path.join(dir, 'pnpm-lock.yaml'), '');
204
+ expect(formatCliCommand('astryx component Button', dir)).toBe('pnpm exec astryx component Button');
205
+ });
206
+
207
+ it('accepts a bare subcommand (no leading astryx)', () => {
208
+ process.argv[1] = '/proj/node_modules/@astryxdesign/cli/bin/astryx.mjs';
209
+ const dir = makeTmpDir();
210
+ fs.writeFileSync(path.join(dir, 'package-lock.json'), '{}');
211
+ expect(formatCliCommand('docs tokens', dir)).toBe('npx astryx docs tokens');
212
+ });
213
+
214
+ it('rewrites to the scoped package for one-off invocations', () => {
215
+ process.argv[1] = '/home/u/.npm/_npx/a1b2/node_modules/.bin/astryx';
216
+ const dir = makeTmpDir();
217
+ fs.writeFileSync(path.join(dir, 'package-lock.json'), '{}');
218
+ expect(formatCliCommand('astryx component Button', dir)).toBe('npx @astryxdesign/cli component Button');
219
+ });
220
+ });
@@ -165,21 +165,3 @@ export function isFilePathArg(pathArg) {
165
165
  const ext = path.extname(base).toLowerCase();
166
166
  return ext.length > 0 && FILE_EXTENSIONS.has(ext);
167
167
  }
168
-
169
- /**
170
- * True when the process is running non-interactively (no TTY) or when the
171
- * caller has signaled JSON / scripted use. Commands consult this before
172
- * prompting for confirmation; in scripted mode they require an explicit
173
- * `--overwrite` flag instead.
174
- *
175
- * @param {object} [options]
176
- * @param {boolean} [options.json] - Caller's --json flag.
177
- * @returns {boolean}
178
- */
179
- export function isNonInteractive({json = false} = {}) {
180
- if (json) return true;
181
- // stdin not a TTY means piped input or scripted execution.
182
- if (process.stdin && process.stdin.isTTY === false) return true;
183
- if (process.stdout && process.stdout.isTTY === false) return true;
184
- return false;
185
- }
@@ -14,12 +14,13 @@
14
14
  import * as fs from 'node:fs';
15
15
  import * as path from 'node:path';
16
16
  import {semverGt} from './semver.mjs';
17
+ import {getCliInvocation} from './package-manager.mjs';
17
18
 
18
19
  /**
19
20
  * Read the latest available version from local signals.
20
- * No network calls — purely filesystem and env var checks.
21
+ * No network calls — purely an env-var check ($ASTRYX_LATEST_VERSION),
22
+ * so it takes no arguments.
21
23
  *
22
- * @param {string} [cwd] - Project directory (default: process.cwd())
23
24
  * @returns {string|null} Latest version string, or null if unknown
24
25
  */
25
26
  export function getLatestVersion() {
@@ -63,7 +64,7 @@ export function getInstalledVersion(cwd = process.cwd()) {
63
64
  * @returns {string|null} FYI hint string, or null if up to date / unknown
64
65
  */
65
66
  export function checkForUpdate(cwd = process.cwd()) {
66
- const latest = getLatestVersion(cwd);
67
+ const latest = getLatestVersion();
67
68
  if (!latest) return null;
68
69
 
69
70
  // Persist for subsequent commands in this shell session
@@ -75,7 +76,7 @@ export function checkForUpdate(cwd = process.cwd()) {
75
76
  // Use semver-aware comparison so '0.0.20' is correctly treated as greater
76
77
  // than '0.0.5' (lexicographic compare gets that backwards).
77
78
  if (semverGt(latest, installed)) {
78
- return `FYI: A newer version of @astryxdesign/core (${latest}) is available. Install the new package version, then run: astryx upgrade --from <old-version> --apply`;
79
+ return `FYI: A newer version of @astryxdesign/core (${latest}) is available. Install the new package version, then run: ${getCliInvocation()} upgrade --from <old-version> --apply`;
79
80
  }
80
81
 
81
82
  return null;
@@ -33,12 +33,12 @@ afterEach(() => {
33
33
  describe('getLatestVersion', () => {
34
34
  it('reads from ASTRYX_LATEST_VERSION env var', () => {
35
35
  process.env.ASTRYX_LATEST_VERSION = '0.0.8';
36
- expect(getLatestVersion(tmpDir)).toBe('0.0.8');
36
+ expect(getLatestVersion()).toBe('0.0.8');
37
37
  });
38
38
 
39
39
  it('ignores invalid env var values', () => {
40
40
  process.env.ASTRYX_LATEST_VERSION = 'not-a-version';
41
- expect(getLatestVersion(tmpDir)).toBeNull();
41
+ expect(getLatestVersion()).toBeNull();
42
42
  });
43
43
 
44
44
  it('returns null when no signals exist', () => {
@@ -46,7 +46,7 @@ describe('getLatestVersion', () => {
46
46
  path.join(tmpDir, 'package.json'),
47
47
  JSON.stringify({name: 'test'}),
48
48
  );
49
- expect(getLatestVersion(tmpDir)).toBeNull();
49
+ expect(getLatestVersion()).toBeNull();
50
50
  });
51
51
  });
52
52