@byline/cli 3.21.0 → 4.0.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 (179) hide show
  1. package/README.md +29 -3
  2. package/dist/cli.js +5 -2
  3. package/dist/cli.js.map +1 -1
  4. package/dist/commands/doctor.d.ts.map +1 -1
  5. package/dist/commands/doctor.js +1 -0
  6. package/dist/commands/doctor.js.map +1 -1
  7. package/dist/commands/init.d.ts +3 -0
  8. package/dist/commands/init.d.ts.map +1 -1
  9. package/dist/commands/init.js +10 -14
  10. package/dist/commands/init.js.map +1 -1
  11. package/dist/commands/setup-checks.d.ts +9 -0
  12. package/dist/commands/setup-checks.d.ts.map +1 -1
  13. package/dist/commands/setup-checks.js +43 -18
  14. package/dist/commands/setup-checks.js.map +1 -1
  15. package/dist/commands/setup.js +1 -1
  16. package/dist/commands/setup.js.map +1 -1
  17. package/dist/context.d.ts +2 -0
  18. package/dist/context.d.ts.map +1 -1
  19. package/dist/context.js +6 -0
  20. package/dist/context.js.map +1 -1
  21. package/dist/lib/dependency-version.d.ts +12 -0
  22. package/dist/lib/dependency-version.d.ts.map +1 -0
  23. package/dist/lib/dependency-version.js +101 -0
  24. package/dist/lib/dependency-version.js.map +1 -0
  25. package/dist/lib/planned-writes.d.ts +8 -0
  26. package/dist/lib/planned-writes.d.ts.map +1 -0
  27. package/dist/lib/planned-writes.js +177 -0
  28. package/dist/lib/planned-writes.js.map +1 -0
  29. package/dist/lib/planned-writes.test.d.ts +2 -0
  30. package/dist/lib/planned-writes.test.d.ts.map +1 -0
  31. package/dist/lib/planned-writes.test.js +113 -0
  32. package/dist/lib/planned-writes.test.js.map +1 -0
  33. package/dist/lib/release-policy.d.ts +12 -0
  34. package/dist/lib/release-policy.d.ts.map +1 -0
  35. package/dist/lib/release-policy.js +20 -0
  36. package/dist/lib/release-policy.js.map +1 -0
  37. package/dist/lib/release-policy.test.d.ts +2 -0
  38. package/dist/lib/release-policy.test.d.ts.map +1 -0
  39. package/dist/lib/release-policy.test.js +36 -0
  40. package/dist/lib/release-policy.test.js.map +1 -0
  41. package/dist/lib/route-config.d.ts +40 -0
  42. package/dist/lib/route-config.d.ts.map +1 -0
  43. package/dist/lib/route-config.js +322 -0
  44. package/dist/lib/route-config.js.map +1 -0
  45. package/dist/lib/static-config.d.ts +20 -0
  46. package/dist/lib/static-config.d.ts.map +1 -0
  47. package/dist/lib/static-config.js +444 -0
  48. package/dist/lib/static-config.js.map +1 -0
  49. package/dist/lib/static-config.test.d.ts +2 -0
  50. package/dist/lib/static-config.test.d.ts.map +1 -0
  51. package/dist/lib/static-config.test.js +105 -0
  52. package/dist/lib/static-config.test.js.map +1 -0
  53. package/dist/lib/template-hook-boundary.test.node.d.ts +2 -0
  54. package/dist/lib/template-hook-boundary.test.node.d.ts.map +1 -0
  55. package/dist/lib/template-hook-boundary.test.node.js +30 -0
  56. package/dist/lib/template-hook-boundary.test.node.js.map +1 -0
  57. package/dist/lib/template-path.d.ts +5 -0
  58. package/dist/lib/template-path.d.ts.map +1 -0
  59. package/dist/lib/template-path.js +9 -0
  60. package/dist/lib/template-path.js.map +1 -0
  61. package/dist/lib/workspace-root.d.ts +28 -0
  62. package/dist/lib/workspace-root.d.ts.map +1 -0
  63. package/dist/lib/workspace-root.js +205 -0
  64. package/dist/lib/workspace-root.js.map +1 -0
  65. package/dist/manifest/deps.d.ts +1 -1
  66. package/dist/manifest/deps.d.ts.map +1 -1
  67. package/dist/manifest/deps.js +18 -8
  68. package/dist/manifest/deps.js.map +1 -1
  69. package/dist/phases/deps.d.ts +14 -1
  70. package/dist/phases/deps.d.ts.map +1 -1
  71. package/dist/phases/deps.js +312 -137
  72. package/dist/phases/deps.js.map +1 -1
  73. package/dist/phases/deps.test.d.ts +2 -0
  74. package/dist/phases/deps.test.d.ts.map +1 -0
  75. package/dist/phases/deps.test.js +343 -0
  76. package/dist/phases/deps.test.js.map +1 -0
  77. package/dist/phases/preflight.d.ts.map +1 -1
  78. package/dist/phases/preflight.js +59 -11
  79. package/dist/phases/preflight.js.map +1 -1
  80. package/dist/phases/prompts.d.ts +2 -0
  81. package/dist/phases/prompts.d.ts.map +1 -1
  82. package/dist/phases/prompts.js +51 -2
  83. package/dist/phases/prompts.js.map +1 -1
  84. package/dist/phases/routes-ui.test.d.ts +2 -0
  85. package/dist/phases/routes-ui.test.d.ts.map +1 -0
  86. package/dist/phases/routes-ui.test.js +766 -0
  87. package/dist/phases/routes-ui.test.js.map +1 -0
  88. package/dist/phases/routes.d.ts +4 -1
  89. package/dist/phases/routes.d.ts.map +1 -1
  90. package/dist/phases/routes.js +610 -122
  91. package/dist/phases/routes.js.map +1 -1
  92. package/dist/phases/scaffold-smoke.test.d.ts +2 -0
  93. package/dist/phases/scaffold-smoke.test.d.ts.map +1 -0
  94. package/dist/phases/scaffold-smoke.test.js +168 -0
  95. package/dist/phases/scaffold-smoke.test.js.map +1 -0
  96. package/dist/phases/scaffold.d.ts +3 -2
  97. package/dist/phases/scaffold.d.ts.map +1 -1
  98. package/dist/phases/scaffold.js +268 -91
  99. package/dist/phases/scaffold.js.map +1 -1
  100. package/dist/phases/scaffold.test.js +171 -22
  101. package/dist/phases/scaffold.test.js.map +1 -1
  102. package/dist/phases/ui.d.ts +4 -1
  103. package/dist/phases/ui.d.ts.map +1 -1
  104. package/dist/phases/ui.js +51 -59
  105. package/dist/phases/ui.js.map +1 -1
  106. package/dist/phases/wire/index.d.ts.map +1 -1
  107. package/dist/phases/wire/index.js +10 -6
  108. package/dist/phases/wire/index.js.map +1 -1
  109. package/dist/phases/wire/shared.d.ts +4 -1
  110. package/dist/phases/wire/shared.d.ts.map +1 -1
  111. package/dist/phases/wire/vite-config.d.ts.map +1 -1
  112. package/dist/phases/wire/vite-config.js +69 -20
  113. package/dist/phases/wire/vite-config.js.map +1 -1
  114. package/dist/phases/wire/vite-config.test.d.ts +2 -0
  115. package/dist/phases/wire/vite-config.test.d.ts.map +1 -0
  116. package/dist/phases/wire/vite-config.test.js +97 -0
  117. package/dist/phases/wire/vite-config.test.js.map +1 -0
  118. package/dist/prompts.d.ts +1 -0
  119. package/dist/prompts.d.ts.map +1 -1
  120. package/dist/prompts.js +7 -2
  121. package/dist/prompts.js.map +1 -1
  122. package/dist/runner.js +1 -1
  123. package/dist/runner.js.map +1 -1
  124. package/dist/templates/byline/clients.server.ts +77 -0
  125. package/dist/templates/byline/collection-types.contract.ts +25 -0
  126. package/dist/templates/byline/collections/server-hooks.ts +7 -0
  127. package/dist/templates/byline/routes.ts +7 -6
  128. package/dist/templates/byline/server.config.ts +3 -1
  129. package/dist/templates/byline-examples/admin.config.ts +7 -12
  130. package/dist/templates/byline-examples/blocks/photo-block.ts +3 -2
  131. package/dist/templates/byline-examples/clients.server.ts +77 -0
  132. package/dist/templates/byline-examples/collection-types.contract.ts +25 -0
  133. package/dist/templates/byline-examples/collections/docs/hooks.ts +23 -13
  134. package/dist/templates/byline-examples/collections/docs/schema.ts +2 -11
  135. package/dist/templates/byline-examples/collections/media/components/media-list-view.tsx +11 -6
  136. package/dist/templates/byline-examples/collections/media/schema.ts +3 -0
  137. package/dist/templates/byline-examples/collections/news/schema.ts +4 -3
  138. package/dist/templates/byline-examples/collections/server-hooks.ts +8 -0
  139. package/dist/templates/byline-examples/fields/ai-text.ts +1 -1
  140. package/dist/templates/byline-examples/fields/ai-textarea.ts +1 -1
  141. package/dist/templates/byline-examples/fields/lexical-richtext-ai.tsx +3 -3
  142. package/dist/templates/byline-examples/routes.ts +7 -6
  143. package/dist/templates/byline-examples/scripts/backfill-version-locales.ts +1 -1
  144. package/dist/templates/byline-examples/scripts/import-docs.ts +11 -12
  145. package/dist/templates/byline-examples/scripts/lib/mdast-to-lexical.test.node.ts +93 -233
  146. package/dist/templates/byline-examples/scripts/lib/mdast-to-lexical.ts +2 -2
  147. package/dist/templates/byline-examples/scripts/lib/strip-leading-h1.test.node.ts +0 -4
  148. package/dist/templates/byline-examples/scripts/re-anchor.ts +3 -3
  149. package/dist/templates/byline-examples/scripts/regenerate-media.ts +6 -5
  150. package/dist/templates/byline-examples/server.config.ts +5 -4
  151. package/dist/templates/host/vite.config.ts +2 -0
  152. package/dist/templates/routes/_byline/route.lazy.tsx +1 -2
  153. package/dist/templates/routes/_byline/route.tsx +1 -1
  154. package/dist/templates/ui-byline/blocks/photo-block/index.tsx +18 -22
  155. package/dist/templates/ui-byline/components/admonition/index.tsx +11 -8
  156. package/dist/templates/ui-byline/components/code/code.tsx +6 -4
  157. package/dist/templates/ui-byline/components/link/lang-link.tsx +4 -8
  158. package/dist/templates/ui-byline/components/link/link-field.tsx +3 -2
  159. package/dist/templates/ui-byline/components/link/link-lexical.tsx +9 -8
  160. package/dist/templates/ui-byline/render-blocks.tsx +38 -34
  161. package/dist/templates/ui-byline/types/content.ts +25 -0
  162. package/dist/templates/ui-byline/types/i18n.ts +4 -8
  163. package/dist/test-helpers.d.ts +5 -0
  164. package/dist/test-helpers.d.ts.map +1 -0
  165. package/dist/test-helpers.js +52 -0
  166. package/dist/test-helpers.js.map +1 -0
  167. package/dist/types.d.ts +21 -1
  168. package/dist/types.d.ts.map +1 -1
  169. package/dist/ui-template-contract.test.d.ts +2 -0
  170. package/dist/ui-template-contract.test.d.ts.map +1 -0
  171. package/dist/ui-template-contract.test.js +130 -0
  172. package/dist/ui-template-contract.test.js.map +1 -0
  173. package/dist/workspace-root.test.d.ts +2 -0
  174. package/dist/workspace-root.test.d.ts.map +1 -0
  175. package/dist/workspace-root.test.js +307 -0
  176. package/dist/workspace-root.test.js.map +1 -0
  177. package/package.json +9 -1
  178. package/dist/templates/byline/client.server.ts +0 -32
  179. package/dist/templates/byline-examples/client.server.ts +0 -32
@@ -1,159 +1,540 @@
1
- import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
1
+ import { existsSync, lstatSync, readdirSync, readFileSync, rmdirSync } from 'node:fs';
2
2
  import { dirname, join, relative } from 'node:path';
3
+ import { applyPlannedWrites } from '../lib/planned-writes.js';
4
+ import { canonicalRoutesTemplatePath, DEFAULT_SIGN_IN_PATH, readStaticRoutesSource, recognizeGeneratedRoutesSource, renderGeneratedRoutesSource, routesSourceAligned, validateCanonicalRoutesSource, validateRoutePaths, } from '../lib/route-config.js';
5
+ import { normalizeTemplateSource, toPosixTemplatePath } from '../lib/template-path.js';
3
6
  const TEMPLATE_DIR = 'routes/_byline';
4
7
  const TARGET_GROUP_DIR = 'src/routes/_byline';
5
- const DEFAULT_ADMIN_PATH = '/admin';
6
- const ADMIN_SLUG_REGEX = /^[a-z][a-z0-9-]*$/;
8
+ const RUNTIME_ROUTES = 'byline/routes.ts';
9
+ const SIGN_IN_TEMPLATE = 'sign-in.tsx';
7
10
  export const routesPhase = {
8
11
  id: 'routes',
9
- title: 'Routes — drop _byline/admin pathless layout into src/routes/',
12
+ title: 'Routes — install the _byline pathless route group',
10
13
  defaultMode: 'confirm',
11
14
  async detect(ctx) {
12
- if (ctx.state.isComplete('routes'))
13
- return 'done';
14
- return 'pending';
15
+ const answers = ctx.state.get().answers;
16
+ const validation = validateRoutePaths(ctx, answers.adminPath, answers.signInPath);
17
+ if (!validation.ok)
18
+ return 'pending';
19
+ return inspectRoutes(ctx, validation.value).complete ? 'done' : 'pending';
15
20
  },
16
21
  async plan(ctx) {
17
- const adminPath = ctx.state.get().answers.adminPath ?? DEFAULT_ADMIN_PATH;
18
- const slug = pathToSlug(adminPath);
19
- if (slug === null) {
20
- return {
21
- writes: [],
22
- commands: [],
23
- notes: [
24
- `invalid admin path "${adminPath}" — must be a single URL segment matching ${ADMIN_SLUG_REGEX}`,
25
- ],
26
- };
27
- }
28
- const entries = collectEntries(ctx, slug);
29
- const created = entries.filter((e) => e.status === 'create').length;
30
- const skipped = entries.filter((e) => e.status === 'skip-already-exists').length;
31
- const notes = [
32
- `target: ${ctx.resolve(TARGET_GROUP_DIR)}/`,
33
- `admin path: ${ctx.state.get().answers.adminPath === undefined ? `will prompt (default ${DEFAULT_ADMIN_PATH})` : adminPath}`,
34
- slug !== 'admin'
35
- ? `pathless layout: _byline/${slug} (renamed from _byline/admin)`
36
- : 'pathless layout: _byline/admin',
37
- `${created} file(s) to create, ${skipped} already-existing skipped`,
38
- ];
39
- return { writes: [], commands: [], notes };
22
+ return buildRoutesPlan(ctx);
40
23
  },
41
- async apply(_plan, ctx) {
42
- let adminPath = ctx.state.get().answers.adminPath;
43
- if (adminPath === undefined) {
44
- adminPath = await ctx.prompter.text({
45
- message: 'Where should the admin UI be mounted?',
46
- defaultValue: DEFAULT_ADMIN_PATH,
47
- placeholder: DEFAULT_ADMIN_PATH,
48
- });
49
- adminPath = adminPath.startsWith('/') ? adminPath : `/${adminPath}`;
50
- }
51
- const slug = pathToSlug(adminPath);
52
- if (!slug) {
53
- ctx.logger.error(`invalid admin path "${adminPath}" — must be a single URL segment matching ${ADMIN_SLUG_REGEX}`);
24
+ async apply(plan, ctx) {
25
+ const answers = ctx.state.get().answers;
26
+ const validation = validateRoutePaths(ctx, answers.adminPath, answers.signInPath);
27
+ if (!validation.ok) {
28
+ ctx.logger.error(validation.error);
29
+ return { state: 'blocked' };
30
+ }
31
+ const canonical = canonicalRuntimeRoutesSource(ctx);
32
+ if (!canonical.ok) {
33
+ ctx.logger.error(canonical.error);
54
34
  return { state: 'blocked' };
55
35
  }
56
- ctx.state.patchAnswers({ adminPath });
57
- const entries = collectEntries(ctx, slug);
58
- if (entries.length === 0) {
59
- ctx.logger.error('no route templates found — was the cli built with templates copied to dist?');
36
+ if (expectedRouteWrites(ctx, validation.value).length === 0) {
37
+ ctx.logger.error('no route templates found — was the CLI built with templates?');
60
38
  return { state: 'blocked' };
61
39
  }
62
- let written = 0;
63
- let skipped = 0;
64
- for (const entry of entries) {
65
- if (entry.status === 'skip-already-exists') {
66
- skipped++;
40
+ let stalePreconditionPath;
41
+ for (const precondition of plan.preconditions ?? []) {
42
+ if (precondition.type === 'value')
67
43
  continue;
44
+ const stale = precondition.type === 'file'
45
+ ? currentFileContents(precondition.path) !== precondition.contents
46
+ : !treeSnapshotsEqual(snapshotTree(precondition.path), precondition.entries);
47
+ if (stale) {
48
+ stalePreconditionPath = precondition.path;
49
+ break;
68
50
  }
69
- mkdirSync(dirname(entry.toAbs), { recursive: true });
70
- const raw = readFileSync(entry.fromAbs, 'utf8');
71
- writeFileSync(entry.toAbs, rewriteRouteIds(raw, slug), 'utf8');
72
- written++;
73
- }
74
- ctx.logger.success(`wrote ${written} route file(s) under ${ctx.resolve(TARGET_GROUP_DIR)}/`);
75
- if (skipped > 0) {
76
- ctx.logger.info(`${skipped} file(s) already existed and were left untouched — re-running routes is non-destructive`);
77
- }
78
- ctx.prompter.note([
79
- 'The routes were dropped under a TanStack Router pathless layout',
80
- "(`src/routes/_byline/`). The `_byline` segment doesn't add to the URL —",
81
- '`/_byline/admin/users` resolves to `/admin/users` — but it does scope',
82
- 'the Byline UI kit stylesheets (loaded by `_byline/route.tsx`) to admin',
83
- '+ sign-in pages only.',
84
- '',
85
- 'If your app has front-end routes at the root of `src/routes/`, consider',
86
- 'moving them under their own pathless layout (e.g. `_front-end/`) so the',
87
- "Byline reset / styles don't bleed into them. If you DO want to use the",
88
- 'Byline UI kit on your public site too, just import the same stylesheets',
89
- 'from your front-end layout:',
90
- '',
91
- " import '@byline/ui/reset.css'",
92
- " import '@byline/ui/styles.css'",
93
- '',
94
- 'See the reference webapp at https://github.com/Byline-CMS/bylinecms.dev',
95
- '— `apps/webapp/src/routes/_byline/route.tsx` (admin) and',
96
- ' `apps/webapp/src/routes/{-$lng}/_public/route.tsx` (front-end) both',
97
- 'demonstrate this pattern.',
98
- ].join('\n'), 'Pathless layout + style isolation');
99
- return { state: 'done' };
51
+ }
52
+ if (stalePreconditionPath) {
53
+ ctx.logger.warn(`${stalePreconditionPath} changed after preview; no route changes applied`);
54
+ return { state: 'partial' };
55
+ }
56
+ ctx.state.patchAnswers({
57
+ adminPath: validation.value.adminPath,
58
+ signInPath: validation.value.signInPath,
59
+ });
60
+ const result = applyPlannedWrites(plan.writes);
61
+ if (result.written.length > 0) {
62
+ ctx.logger.success(`wrote ${result.written.length} planned route file(s)`);
63
+ }
64
+ if (result.conflicts.length > 0) {
65
+ ctx.logger.warn('route files changed after preview and were left untouched');
66
+ return { state: 'partial' };
67
+ }
68
+ removeEmptyDeletedRouteDirectories(plan.writes, ctx.resolve(TARGET_GROUP_DIR));
69
+ const migrationResiduals = findMigrationResiduals(plan, ctx, validation.value);
70
+ for (const path of migrationResiduals) {
71
+ ctx.logger.warn(`${path}: manual — residual file remains in the old admin tree`);
72
+ }
73
+ const inspection = inspectRoutes(ctx, validation.value);
74
+ for (const note of inspection.manualNotes)
75
+ ctx.logger.warn(note);
76
+ return { state: inspection.complete && migrationResiduals.length === 0 ? 'done' : 'partial' };
100
77
  },
101
78
  };
102
- function pathToSlug(adminPath) {
103
- const trimmed = adminPath.replace(/^\/+|\/+$/g, '');
104
- if (!trimmed || trimmed.includes('/'))
105
- return null;
106
- return ADMIN_SLUG_REGEX.test(trimmed) ? trimmed : null;
79
+ export function buildRoutesPlan(ctx) {
80
+ const answers = ctx.state.get().answers;
81
+ const validation = validateRoutePaths(ctx, answers.adminPath, answers.signInPath);
82
+ if (!validation.ok)
83
+ return { writes: [], commands: [], notes: [validation.error] };
84
+ const routes = validation.value;
85
+ const canonical = canonicalRuntimeRoutesSource(ctx);
86
+ if (!canonical.ok)
87
+ return { writes: [], commands: [], notes: [canonical.error] };
88
+ if (expectedRouteWrites(ctx, routes).length === 0) {
89
+ return {
90
+ writes: [],
91
+ commands: [],
92
+ notes: ['no route templates found — was the CLI built with templates?'],
93
+ };
94
+ }
95
+ const routeFiles = planRouteFileWrites(ctx, routes);
96
+ if (routeFiles.blocked) {
97
+ return {
98
+ writes: [],
99
+ commands: [],
100
+ notes: [
101
+ `target: ${ctx.resolve(TARGET_GROUP_DIR)}/`,
102
+ `admin path: ${routes.adminPath}`,
103
+ `sign-in path: ${routes.signInPath}`,
104
+ '0 planned route/config change(s)',
105
+ ...routeFiles.notes,
106
+ ],
107
+ };
108
+ }
109
+ const runtime = planRuntimeRoutesWrite(ctx, routes, canonical.source);
110
+ let writes = routeFiles.writes;
111
+ const notes = [...routeFiles.notes];
112
+ if (runtime.migrationBlocked) {
113
+ if (runtime.note)
114
+ notes.push(runtime.note);
115
+ return {
116
+ writes: [],
117
+ commands: [],
118
+ notes: [
119
+ `target: ${ctx.resolve(TARGET_GROUP_DIR)}/`,
120
+ `admin path: ${routes.adminPath}`,
121
+ `sign-in path: ${routes.signInPath}`,
122
+ '0 planned route/config change(s)',
123
+ ...notes,
124
+ ],
125
+ };
126
+ }
127
+ const previous = runtime.previousRoutes;
128
+ const generatedMigration = previous &&
129
+ (previous.adminPath !== routes.adminPath || previous.signInPath !== routes.signInPath)
130
+ ? planGeneratedRoutesMigration(ctx, previous, routes)
131
+ : undefined;
132
+ if (generatedMigration && (!generatedMigration.safe || runtime.write === undefined)) {
133
+ return {
134
+ writes: [],
135
+ commands: [],
136
+ notes: [
137
+ `target: ${ctx.resolve(TARGET_GROUP_DIR)}/`,
138
+ `admin path: ${routes.adminPath}`,
139
+ `sign-in path: ${routes.signInPath}`,
140
+ '0 planned route/config change(s)',
141
+ generatedMigration.note ??
142
+ `${ctx.resolve(RUNTIME_ROUTES)}: manual — route migration was left unchanged because every coupled file was not recognized as generated and safe`,
143
+ ],
144
+ };
145
+ }
146
+ if (generatedMigration)
147
+ writes = generatedMigration.writes;
148
+ const defaultSignInPath = ctx.resolve(TARGET_GROUP_DIR, SIGN_IN_TEMPLATE);
149
+ if (!generatedMigration &&
150
+ routeFiles.signInPath !== defaultSignInPath &&
151
+ existsSync(defaultSignInPath)) {
152
+ notes.push(`${defaultSignInPath}: manual — existing sign-in route was preserved`);
153
+ }
154
+ if (runtime.write)
155
+ writes.push(runtime.write);
156
+ if (generatedMigration) {
157
+ const deletes = writes.filter((write) => write.mode === 'delete');
158
+ writes = [...writes.filter((write) => write.mode !== 'delete'), ...deletes];
159
+ }
160
+ if (runtime.note)
161
+ notes.push(runtime.note);
162
+ const planNotes = [
163
+ `target: ${ctx.resolve(TARGET_GROUP_DIR)}/`,
164
+ `admin path: ${routes.adminPath}`,
165
+ `sign-in path: ${routes.signInPath}`,
166
+ `${writes.length} planned route/config change(s)`,
167
+ ...notes,
168
+ ];
169
+ const preconditions = [
170
+ { type: 'file', path: canonical.path, contents: canonical.source },
171
+ ...(runtime.precondition ? [runtime.precondition] : []),
172
+ ...(generatedMigration?.preconditions ?? []),
173
+ ];
174
+ return { writes, commands: [], notes: planNotes, preconditions };
107
175
  }
108
- function collectEntries(ctx, slug) {
176
+ function expectedRouteWrites(ctx, routes) {
109
177
  const templateRoot = join(ctx.templatesDir(), TEMPLATE_DIR);
110
178
  if (!existsSync(templateRoot))
111
179
  return [];
112
180
  const targetRoot = ctx.resolve(TARGET_GROUP_DIR);
113
- const entries = [];
114
- for (const abs of walkFiles(templateRoot)) {
115
- const rel = relative(templateRoot, abs);
116
- const renamed = slug === 'admin' ? rel : renameAdminSegment(rel, slug);
117
- const toAbs = join(targetRoot, renamed);
118
- entries.push({
119
- fromAbs: abs,
120
- rel: renamed,
121
- toAbs,
122
- status: existsSync(toAbs) ? 'skip-already-exists' : 'create',
123
- });
181
+ return walkFiles(templateRoot).map((abs) => {
182
+ const rel = toPosixTemplatePath(relative(templateRoot, abs));
183
+ const kind = rel === SIGN_IN_TEMPLATE ? 'signIn' : rel.startsWith('admin/') ? 'admin' : 'shared';
184
+ const renamed = rel === SIGN_IN_TEMPLATE
185
+ ? `${routes.signInSegments.join('/')}.tsx`
186
+ : renameAdminSegment(rel, routes.adminSegments);
187
+ return {
188
+ rel,
189
+ kind,
190
+ path: join(targetRoot, renamed),
191
+ contents: rewriteRouteIds(readFileSync(abs, 'utf8'), routes),
192
+ mode: 'create',
193
+ };
194
+ });
195
+ }
196
+ function planRouteFileWrites(ctx, routes) {
197
+ const writes = [];
198
+ const notes = [];
199
+ const signInPath = signInRouteFilePath(ctx, routes);
200
+ for (const expected of expectedRouteWrites(ctx, routes)) {
201
+ const invalidParent = firstInvalidParent(expected.path);
202
+ if (invalidParent) {
203
+ return {
204
+ writes: [],
205
+ notes: [
206
+ `${expected.path}: manual — expected route target has a non-directory parent at ${invalidParent}`,
207
+ ],
208
+ signInPath,
209
+ blocked: true,
210
+ };
211
+ }
212
+ const target = lstatIfPresent(expected.path);
213
+ if (!target) {
214
+ writes.push(expected);
215
+ }
216
+ else if (!target.isFile()) {
217
+ return {
218
+ writes: [],
219
+ notes: [`${expected.path}: manual — expected route target is not a regular file`],
220
+ signInPath,
221
+ blocked: true,
222
+ };
223
+ }
224
+ else if (normalizeTemplateSource(readFileSync(expected.path, 'utf8')) !==
225
+ normalizeTemplateSource(expected.contents)) {
226
+ notes.push(`${expected.path}: manual — existing route file differs from the template`);
227
+ }
124
228
  }
125
- return entries;
229
+ return { writes, notes, signInPath, blocked: false };
230
+ }
231
+ function planRuntimeRoutesWrite(ctx, routes, canonical) {
232
+ const path = ctx.resolve(RUNTIME_ROUTES);
233
+ const desired = renderGeneratedRoutesSource(canonical, routes.adminPath, routes.apiPath, routes.signInPath);
234
+ const invalidParent = firstInvalidParent(path);
235
+ if (invalidParent) {
236
+ return {
237
+ note: `${path}: manual — routes.ts target has a non-directory parent at ${invalidParent}`,
238
+ migrationBlocked: true,
239
+ };
240
+ }
241
+ const target = lstatIfPresent(path);
242
+ if (!target)
243
+ return { write: { path, contents: desired, mode: 'create' } };
244
+ if (!target.isFile()) {
245
+ return {
246
+ note: `${path}: manual — routes.ts target is not a regular file`,
247
+ migrationBlocked: true,
248
+ };
249
+ }
250
+ const before = readFileSync(path, 'utf8');
251
+ const staticRoutes = readStaticRoutesSource(before);
252
+ if (!staticRoutes.ok) {
253
+ return {
254
+ note: `${path}: manual — existing routes.ts cannot be resolved statically`,
255
+ migrationBlocked: true,
256
+ };
257
+ }
258
+ const previous = validateRoutePaths(ctx, staticRoutes.value.admin, staticRoutes.value.signIn, staticRoutes.value.api);
259
+ if (!previous.ok) {
260
+ return {
261
+ note: `${path}: manual — existing routes.ts has invalid route paths`,
262
+ migrationBlocked: true,
263
+ };
264
+ }
265
+ const generated = recognizeGeneratedRoutesSource(before, canonical);
266
+ if (generated) {
267
+ const generatedPrevious = validateRoutePaths(ctx, generated.adminPath, generated.signInPath, generated.apiPath);
268
+ if (!generatedPrevious.ok) {
269
+ return {
270
+ note: `${path}: manual — generated predecessor has invalid route paths`,
271
+ migrationBlocked: true,
272
+ };
273
+ }
274
+ if (normalizeTemplateSource(before) === normalizeTemplateSource(desired)) {
275
+ return { precondition: { type: 'file', path, contents: before } };
276
+ }
277
+ return {
278
+ write: { path, contents: desired, mode: 'patch', before },
279
+ previousRoutes: generatedPrevious.value,
280
+ };
281
+ }
282
+ if (previous.value.adminPath === routes.adminPath &&
283
+ previous.value.apiPath === routes.apiPath &&
284
+ previous.value.signInPath === routes.signInPath) {
285
+ return { precondition: { type: 'file', path, contents: before } };
286
+ }
287
+ return {
288
+ note: `${path}: manual — existing user-owned routes.ts does not match a generated predecessor`,
289
+ migrationBlocked: true,
290
+ };
291
+ }
292
+ function runtimeRoutesAligned(ctx, routes) {
293
+ const path = ctx.resolve(RUNTIME_ROUTES);
294
+ if (firstInvalidParent(path))
295
+ return false;
296
+ if (!lstatIfPresent(path)?.isFile())
297
+ return false;
298
+ return routesSourceAligned(readFileSync(path, 'utf8'), ctx, routes);
299
+ }
300
+ function canonicalRuntimeRoutesSource(ctx) {
301
+ const path = canonicalRoutesTemplatePath(ctx);
302
+ if (!existsSync(path)) {
303
+ return { ok: false, error: `${path}: manual — canonical routes.ts template is missing` };
304
+ }
305
+ const source = readFileSync(path, 'utf8');
306
+ const validation = validateCanonicalRoutesSource(source);
307
+ return validation.ok
308
+ ? { ok: true, path, source }
309
+ : {
310
+ ok: false,
311
+ error: `${path}: manual — canonical routes.ts template is invalid (${validation.error})`,
312
+ };
313
+ }
314
+ function inspectRoutes(ctx, routes) {
315
+ const expected = expectedRouteWrites(ctx, routes);
316
+ if (expected.length === 0) {
317
+ return { complete: false, manualNotes: ['no route templates found'] };
318
+ }
319
+ const manualNotes = [];
320
+ for (const route of expected) {
321
+ const invalidParent = firstInvalidParent(route.path);
322
+ if (invalidParent) {
323
+ manualNotes.push(`${route.path}: manual — expected route target has a non-directory parent at ${invalidParent}`);
324
+ continue;
325
+ }
326
+ const target = lstatIfPresent(route.path);
327
+ if (!target) {
328
+ manualNotes.push(`${route.path}: missing generated route`);
329
+ }
330
+ else if (!target.isFile()) {
331
+ manualNotes.push(`${route.path}: manual — expected route target is not a regular file`);
332
+ }
333
+ else if (normalizeTemplateSource(readFileSync(route.path, 'utf8')) !==
334
+ normalizeTemplateSource(route.contents)) {
335
+ manualNotes.push(`${route.path}: manual — existing route file differs from the template`);
336
+ }
337
+ }
338
+ for (const generated of findGeneratedSignInRoutes(ctx)) {
339
+ if (generated.routePath !== routes.signInPath) {
340
+ manualNotes.push(`${generated.filePath}: manual — old generated sign-in route still exists`);
341
+ }
342
+ }
343
+ for (const generated of findGeneratedAdminRoutes(ctx, routes)) {
344
+ if (generated.adminPath !== routes.adminPath) {
345
+ manualNotes.push(`${generated.filePath}: manual — old generated admin tree still exists`);
346
+ }
347
+ }
348
+ const defaultSignInPath = ctx.resolve(TARGET_GROUP_DIR, SIGN_IN_TEMPLATE);
349
+ if (routes.signInPath !== DEFAULT_SIGN_IN_PATH && existsSync(defaultSignInPath)) {
350
+ manualNotes.push(`${defaultSignInPath}: manual — existing sign-in route still exists`);
351
+ }
352
+ if (!runtimeRoutesAligned(ctx, routes)) {
353
+ manualNotes.push(`${ctx.resolve(RUNTIME_ROUTES)}: manual — routes.admin and routes.signIn must match ${routes.adminPath} and ${routes.signInPath}`);
354
+ }
355
+ return { complete: manualNotes.length === 0, manualNotes };
126
356
  }
127
- /**
128
- * Replace the leading `admin/` (or exactly `admin`) directory segment with `<slug>/`.
129
- * Files outside the admin/ subtree (e.g. `sign-in.tsx`) are returned unchanged.
130
- */
131
- function renameAdminSegment(rel, slug) {
357
+ function planGeneratedRoutesMigration(ctx, previous, desired) {
358
+ const previousFiles = expectedRouteWrites(ctx, previous);
359
+ const desiredFiles = expectedRouteWrites(ctx, desired);
360
+ const previousByPath = new Map(previousFiles.map((file) => [file.path, file]));
361
+ const desiredByPath = new Map(desiredFiles.map((file) => [file.path, file]));
362
+ const writes = [];
363
+ const preconditions = [];
364
+ const oldAdminPaths = new Set(previousFiles.filter((file) => file.kind === 'admin').map((file) => file.path));
365
+ for (const old of previousFiles) {
366
+ const invalidParent = firstInvalidParent(old.path);
367
+ if (invalidParent) {
368
+ return migrationFailure(old.path, `old route has a non-directory parent at ${invalidParent}`);
369
+ }
370
+ const target = lstatIfPresent(old.path);
371
+ if (!target)
372
+ continue;
373
+ if (!target.isFile()) {
374
+ return migrationFailure(old.path, 'existing old route target is not a regular file');
375
+ }
376
+ const before = readFileSync(old.path, 'utf8');
377
+ const next = desiredByPath.get(old.path);
378
+ const matchesOld = sourcesEqual(before, old.contents);
379
+ const matchesDesired = next !== undefined && sourcesEqual(before, next.contents);
380
+ if (!matchesOld && !matchesDesired) {
381
+ const label = old.kind === 'signIn' ? 'sign-in route' : `${old.kind} route file`;
382
+ return migrationFailure(old.path, `existing old ${label} is user-owned`);
383
+ }
384
+ }
385
+ for (const next of desiredFiles) {
386
+ const invalidParent = firstInvalidParent(next.path);
387
+ if (invalidParent) {
388
+ return migrationFailure(next.path, `migration destination has a non-directory parent at ${invalidParent}`);
389
+ }
390
+ const target = lstatIfPresent(next.path);
391
+ if (!target) {
392
+ writes.push(next);
393
+ continue;
394
+ }
395
+ if (!target.isFile()) {
396
+ return migrationFailure(next.path, 'migration destination is not a regular file');
397
+ }
398
+ const before = readFileSync(next.path, 'utf8');
399
+ if (sourcesEqual(before, next.contents)) {
400
+ preconditions.push({ type: 'file', path: next.path, contents: before });
401
+ continue;
402
+ }
403
+ const oldAtDestination = previousByPath.get(next.path);
404
+ if (oldAtDestination && sourcesEqual(before, oldAtDestination.contents)) {
405
+ writes.push({ path: next.path, contents: next.contents, mode: 'patch', before });
406
+ continue;
407
+ }
408
+ return migrationFailure(next.path, 'migration destination is user-owned');
409
+ }
410
+ for (const old of previousFiles) {
411
+ if (desiredByPath.has(old.path) || !existsSync(old.path))
412
+ continue;
413
+ const before = readFileSync(old.path, 'utf8');
414
+ writes.push({ path: old.path, contents: '', mode: 'delete', before });
415
+ }
416
+ if (previous.adminPath !== desired.adminPath) {
417
+ const oldRoot = ctx.resolve(TARGET_GROUP_DIR, ...previous.adminSegments);
418
+ const oldTree = snapshotTree(oldRoot);
419
+ if (oldTree.length > 0) {
420
+ if (oldTree[0]?.type !== 'directory') {
421
+ return migrationFailure(oldRoot, 'old admin tree root is user-owned');
422
+ }
423
+ const allowedPaths = new Set([
424
+ ...oldAdminPaths,
425
+ ...desiredFiles.filter((file) => file.kind === 'admin').map((file) => file.path),
426
+ ]);
427
+ for (const entry of oldTree) {
428
+ if (entry.path === oldRoot)
429
+ continue;
430
+ if (entry.type === 'directory' && isManagedDirectory(entry.path, allowedPaths))
431
+ continue;
432
+ if (entry.type === 'file' && oldAdminPaths.has(entry.path))
433
+ continue;
434
+ const next = desiredByPath.get(entry.path);
435
+ if (entry.type === 'file' && next && sourcesEqual(entry.contents ?? '', next.contents)) {
436
+ continue;
437
+ }
438
+ return migrationFailure(entry.path, 'old admin tree contains a user-owned file');
439
+ }
440
+ }
441
+ preconditions.push({ type: 'tree', path: oldRoot, entries: oldTree });
442
+ }
443
+ return { safe: true, writes, preconditions };
444
+ }
445
+ function sourcesEqual(left, right) {
446
+ return normalizeTemplateSource(left) === normalizeTemplateSource(right);
447
+ }
448
+ function migrationFailure(path, reason) {
449
+ return {
450
+ safe: false,
451
+ writes: [],
452
+ preconditions: [],
453
+ note: `${path}: manual — ${reason}; no coupled route changes were planned`,
454
+ };
455
+ }
456
+ function signInRouteFilePath(ctx, routes) {
457
+ return ctx.resolve(TARGET_GROUP_DIR, `${routes.signInSegments.join('/')}.tsx`);
458
+ }
459
+ function expectedSignInRouteSource(ctx, signInPath) {
460
+ const templatePath = join(ctx.templatesDir(), TEMPLATE_DIR, SIGN_IN_TEMPLATE);
461
+ if (!existsSync(templatePath))
462
+ return null;
463
+ return readFileSync(templatePath, 'utf8').replace(/(['"])\/_byline\/sign-in\b/g, `$1/_byline${signInPath}`);
464
+ }
465
+ function findGeneratedSignInRoutes(ctx) {
466
+ const root = ctx.resolve(TARGET_GROUP_DIR);
467
+ if (!existsSync(root))
468
+ return [];
469
+ const generated = [];
470
+ for (const filePath of walkFiles(root)) {
471
+ if (!filePath.endsWith('.tsx'))
472
+ continue;
473
+ const routePath = `/${toPosixTemplatePath(relative(root, filePath)).replace(/\.tsx$/, '')}`;
474
+ const expected = expectedSignInRouteSource(ctx, routePath);
475
+ if (expected !== null &&
476
+ normalizeTemplateSource(readFileSync(filePath, 'utf8')) === normalizeTemplateSource(expected)) {
477
+ generated.push({ filePath, routePath });
478
+ }
479
+ }
480
+ return generated;
481
+ }
482
+ function findGeneratedAdminRoutes(ctx, routes) {
483
+ const root = ctx.resolve(TARGET_GROUP_DIR);
484
+ const templateRoot = join(ctx.templatesDir(), TEMPLATE_DIR);
485
+ const adminTemplateRoot = join(templateRoot, 'admin');
486
+ if (!existsSync(root) || !existsSync(adminTemplateRoot))
487
+ return [];
488
+ const adminTemplates = walkFiles(adminTemplateRoot).map((templatePath) => ({
489
+ templatePath,
490
+ suffix: toPosixTemplatePath(relative(adminTemplateRoot, templatePath)),
491
+ }));
492
+ const generated = [];
493
+ for (const filePath of walkFiles(root)) {
494
+ const rel = toPosixTemplatePath(relative(root, filePath));
495
+ for (const template of adminTemplates) {
496
+ if (rel !== template.suffix && !rel.endsWith(`/${template.suffix}`))
497
+ continue;
498
+ const prefixLength = rel.length - template.suffix.length;
499
+ const adminRel = rel.slice(0, prefixLength).replace(/\/$/, '');
500
+ if (!adminRel)
501
+ continue;
502
+ const adminSegments = adminRel.split('/');
503
+ const candidate = {
504
+ ...routes,
505
+ adminPath: `/${adminRel}`,
506
+ adminSegments,
507
+ };
508
+ const expected = rewriteRouteIds(readFileSync(template.templatePath, 'utf8'), candidate);
509
+ if (normalizeTemplateSource(readFileSync(filePath, 'utf8')) ===
510
+ normalizeTemplateSource(expected)) {
511
+ generated.push({ filePath, adminPath: candidate.adminPath });
512
+ break;
513
+ }
514
+ }
515
+ }
516
+ return generated;
517
+ }
518
+ export function renameAdminSegment(rel, segments) {
519
+ rel = toPosixTemplatePath(rel);
132
520
  if (rel === 'admin' || rel.startsWith('admin/')) {
133
- return `${slug}${rel.slice('admin'.length)}`;
521
+ return `${segments.join('/')}${rel.slice('admin'.length)}`;
134
522
  }
135
523
  return rel;
136
524
  }
137
- /**
138
- * Rewrite route-id strings inside the file contents. Route ids are passed
139
- * positionally to the `create*Route(...)` factories from
140
- * `@byline/host-tanstack-start/routes` and look like `/_byline/admin/...`.
141
- *
142
- * We match the literal `/_byline/admin` prefix only — we do not touch
143
- * other occurrences of "admin" elsewhere in the file (comments, prop
144
- * names, etc.).
145
- */
146
- function rewriteRouteIds(source, slug) {
147
- if (slug === 'admin')
148
- return source;
149
- return source.replace(/(['"])\/_byline\/admin\b/g, `$1/_byline/${slug}`);
525
+ function rewriteRouteIds(source, routes) {
526
+ return source
527
+ .replace(/(['"])\/_byline\/admin\b/g, `$1/_byline${routes.adminPath}`)
528
+ .replace(/(['"])\/_byline\/sign-in\b/g, `$1/_byline${routes.signInPath}`);
150
529
  }
151
530
  function walkFiles(root) {
152
531
  const out = [];
532
+ if (!lstatIfPresent(root)?.isDirectory())
533
+ return out;
153
534
  function walk(dir) {
154
- for (const name of readdirSync(dir)) {
535
+ for (const name of readdirSync(dir).sort()) {
155
536
  const abs = join(dir, name);
156
- const st = statSync(abs);
537
+ const st = lstatSync(abs);
157
538
  if (st.isDirectory())
158
539
  walk(abs);
159
540
  else if (st.isFile())
@@ -163,4 +544,111 @@ function walkFiles(root) {
163
544
  walk(root);
164
545
  return out;
165
546
  }
547
+ function snapshotTree(root) {
548
+ if (!lstatIfPresent(root))
549
+ return [];
550
+ const entries = [];
551
+ function walk(path) {
552
+ const stat = lstatSync(path);
553
+ if (stat.isDirectory()) {
554
+ entries.push({ path, type: 'directory' });
555
+ for (const name of readdirSync(path).sort())
556
+ walk(join(path, name));
557
+ return;
558
+ }
559
+ if (stat.isFile()) {
560
+ entries.push({ path, type: 'file', contents: readFileSync(path, 'utf8') });
561
+ return;
562
+ }
563
+ entries.push({ path, type: 'other' });
564
+ }
565
+ walk(root);
566
+ return entries;
567
+ }
568
+ function treeSnapshotsEqual(left, right) {
569
+ if (left.length !== right.length)
570
+ return false;
571
+ return left.every((entry, index) => {
572
+ const expected = right[index];
573
+ return (expected !== undefined &&
574
+ entry.path === expected.path &&
575
+ entry.type === expected.type &&
576
+ entry.contents === expected.contents);
577
+ });
578
+ }
579
+ function currentFileContents(path) {
580
+ const target = lstatIfPresent(path);
581
+ if (!target)
582
+ return null;
583
+ return target.isFile() ? readFileSync(path, 'utf8') : undefined;
584
+ }
585
+ function lstatIfPresent(path) {
586
+ try {
587
+ return lstatSync(path);
588
+ }
589
+ catch (error) {
590
+ const code = error.code;
591
+ if (code === 'ENOENT' || code === 'ENOTDIR')
592
+ return undefined;
593
+ throw error;
594
+ }
595
+ }
596
+ function firstInvalidParent(path) {
597
+ let parent = dirname(path);
598
+ while (true) {
599
+ const target = lstatIfPresent(parent);
600
+ if (target)
601
+ return target.isDirectory() ? undefined : parent;
602
+ const next = dirname(parent);
603
+ if (next === parent)
604
+ return parent;
605
+ parent = next;
606
+ }
607
+ }
608
+ function isManagedDirectory(path, managedFiles) {
609
+ return [...managedFiles].some((file) => isPathWithin(path, file));
610
+ }
611
+ function isPathWithin(parent, candidate) {
612
+ const rel = relative(parent, candidate);
613
+ return rel !== '' && rel !== '..' && !rel.startsWith('../') && !rel.startsWith('..\\');
614
+ }
615
+ function findMigrationResiduals(plan, ctx, routes) {
616
+ const oldTrees = (plan.preconditions ?? []).filter((precondition) => precondition.type === 'tree');
617
+ if (oldTrees.length === 0)
618
+ return [];
619
+ const desiredAdminPaths = new Set(expectedRouteWrites(ctx, routes)
620
+ .filter((file) => file.kind === 'admin')
621
+ .map((file) => file.path));
622
+ const residuals = [];
623
+ for (const tree of oldTrees) {
624
+ for (const entry of snapshotTree(tree.path)) {
625
+ if (entry.path === tree.path)
626
+ continue;
627
+ if (entry.type === 'directory' && isManagedDirectory(entry.path, desiredAdminPaths))
628
+ continue;
629
+ if (entry.type === 'file' && desiredAdminPaths.has(entry.path))
630
+ continue;
631
+ residuals.push(entry.path);
632
+ }
633
+ }
634
+ return residuals;
635
+ }
636
+ function removeEmptyDeletedRouteDirectories(writes, root) {
637
+ for (const write of writes) {
638
+ if (write.mode !== 'delete')
639
+ continue;
640
+ let directory = dirname(write.path);
641
+ while (directory !== root && directory.startsWith(`${root}/`) && existsSync(directory)) {
642
+ if (readdirSync(directory).length > 0)
643
+ break;
644
+ try {
645
+ rmdirSync(directory);
646
+ }
647
+ catch {
648
+ break;
649
+ }
650
+ directory = dirname(directory);
651
+ }
652
+ }
653
+ }
166
654
  //# sourceMappingURL=routes.js.map