@starci/hfs 4.2.17 → 4.3.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 (108) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +7 -0
  3. package/package.json +1 -1
  4. package/runtime/knowledge/hfs/canon-pins.yaml +4 -4
  5. package/runtime/knowledge/hfs/rules.yaml +41 -13
  6. package/runtime/knowledge/hfs/slots.yaml +99 -43
  7. package/runtime/knowledge/patterns/be/api.yaml +64 -1
  8. package/runtime/knowledge/patterns/be/cli.yaml +23 -5
  9. package/runtime/knowledge/patterns/be/webhooks.yaml +187 -32
  10. package/runtime/knowledge/patterns/fe/folder.yaml +3 -0
  11. package/runtime/modules/kernel/failure-codes.yaml +15 -4
  12. package/runtime/scripts/api/git/cat-file.mjs +5 -0
  13. package/runtime/scripts/api/sops/resolve-sops.mjs +7 -0
  14. package/runtime/scripts/api/sops/seal.mjs +1 -1
  15. package/runtime/scripts/hfs/architecture/backend-app-files.mjs +110 -0
  16. package/runtime/scripts/hfs/architecture/backend.mjs +20 -3
  17. package/runtime/scripts/hfs/architecture/background-unowned.mjs +157 -41
  18. package/runtime/scripts/hfs/architecture/canon-clones.mjs +240 -0
  19. package/runtime/scripts/hfs/architecture/canon-jsx-clones.mjs +143 -0
  20. package/runtime/scripts/hfs/architecture/clones.mjs +17 -8
  21. package/runtime/scripts/hfs/architecture/connection-map.mjs +14 -12
  22. package/runtime/scripts/hfs/architecture/context-map.mjs +25 -14
  23. package/runtime/scripts/hfs/architecture/context-owner.mjs +1 -2
  24. package/runtime/scripts/hfs/architecture/dead-exports.mjs +14 -9
  25. package/runtime/scripts/hfs/architecture/frontend-block-render.mjs +104 -0
  26. package/runtime/scripts/hfs/architecture/frontend-world-render.mjs +30 -11
  27. package/runtime/scripts/hfs/architecture/i18n-keys.mjs +16 -3
  28. package/runtime/scripts/hfs/architecture/machine-ast.mjs +17 -0
  29. package/runtime/scripts/hfs/architecture/module-per-transport.mjs +21 -12
  30. package/runtime/scripts/hfs/architecture/register-once.mjs +13 -5
  31. package/runtime/scripts/hfs/architecture/required-files.mjs +2 -1
  32. package/runtime/scripts/hfs/architecture/staged-consumers.mjs +37 -0
  33. package/runtime/scripts/hfs/architecture/supabase-ast.mjs +3 -6
  34. package/runtime/scripts/hfs/architecture/supabase-be.mjs +1 -6
  35. package/runtime/scripts/hfs/architecture/tiers.mjs +8 -1
  36. package/runtime/scripts/hfs/architecture/typescript.mjs +1 -8
  37. package/runtime/scripts/hfs/architecture/unit-spec-providers.mjs +1 -11
  38. package/runtime/scripts/hfs/check.mjs +18 -26
  39. package/runtime/scripts/hfs/manifest-shape.mjs +27 -2
  40. package/runtime/scripts/hfs/path-findings.mjs +32 -2
  41. package/runtime/scripts/hfs/rules/database-migrations.mjs +36 -35
  42. package/runtime/scripts/hfs/rules/database.mjs +2 -2
  43. package/runtime/scripts/hfs/rules/edition.mjs +5 -5
  44. package/runtime/scripts/hfs/rules/proof-commands.mjs +2 -0
  45. package/runtime/scripts/hfs/rules/secrets.mjs +13 -0
  46. package/runtime/scripts/hfs/rules/stacks-runtime.mjs +128 -0
  47. package/runtime/scripts/hfs/rules/stacks.mjs +13 -8
  48. package/runtime/scripts/hfs/secret-document.mjs +97 -0
  49. package/runtime/scripts/hfs/secret-errors.mjs +20 -0
  50. package/runtime/scripts/hfs/secret-files.mjs +124 -0
  51. package/runtime/scripts/hfs/secret-init.mjs +192 -0
  52. package/runtime/scripts/hfs/secret-output.mjs +100 -0
  53. package/runtime/scripts/hfs/secret-sops.mjs +98 -0
  54. package/runtime/scripts/hfs/secret-store.mjs +161 -0
  55. package/runtime/scripts/hfs/secret.mjs +135 -195
  56. package/runtime/scripts/hfs/slot-imports.mjs +3 -1
  57. package/runtime/scripts/hfs/slot-required.mjs +13 -0
  58. package/runtime/scripts/hfs/slot-semantic-problems.mjs +2 -1
  59. package/runtime/scripts/hfs/tree-findings.mjs +22 -0
  60. package/runtime/scripts/lib/roots.mjs +59 -0
  61. package/runtime/scripts/lib/swarm-stack.mjs +139 -0
  62. package/runtime/scripts/lib/ts-analysis.mjs +20 -0
  63. package/scaffold/add-cli-lite.mjs +10 -11
  64. package/scaffold/add-inbox-lite.mjs +15 -84
  65. package/scaffold/add-plan.mjs +183 -0
  66. package/scaffold/add-table.mjs +44 -5
  67. package/scaffold/add.mjs +33 -166
  68. package/scaffold/app.mjs +5 -5
  69. package/scaffold/image.mjs +2 -2
  70. package/src/main.mjs +12 -6
  71. package/sync/index.mjs +23 -16
  72. package/templates/app/gitignore +1 -0
  73. package/templates/app/gitignore-lite +1 -0
  74. package/templates/app/hooks/husky/pre-commit +7 -12
  75. package/templates/app/skeleton/.sops.yaml +1 -1
  76. package/templates/app/skeleton-lite/.sops.yaml +1 -1
  77. package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +16 -0
  78. package/templates/app/skeleton-lite/.starciwork/workspace.yaml +9 -0
  79. package/templates/app/skeleton-lite/README.md +4 -2
  80. package/templates/app/starciwork.gitignore-lite +6 -0
  81. package/templates/be/inbox/database-client.spec.ts.tpl +15 -20
  82. package/templates/be/inbox/database-client.ts.tpl +7 -22
  83. package/templates/be/inbox/service.spec.ts.tpl +16 -20
  84. package/templates/be/inbox/service.ts.tpl +6 -10
  85. package/templates/be/inbox/sql.ts.tpl +2 -2
  86. package/templates/be/inbox/webhook-door.spec.ts.tpl +3 -3
  87. package/templates/be/inbox/webhook-door.ts.tpl +4 -3
  88. package/templates/be/patterns/webhooks/webhook.door.spec.ts.tpl +3 -3
  89. package/templates/be/patterns/webhooks/webhook.door.ts.tpl +3 -3
  90. package/templates/be/skeleton/apps/__app__/src/__app__.env.ts +21 -0
  91. package/templates/be/skeleton/apps/__app__/src/__app__.options.ts +1 -1
  92. package/templates/be/skeleton/apps/__app__/src/app.module.ts +7 -39
  93. package/templates/be/skeleton/apps/__app__/src/compose/platform.ts +42 -0
  94. package/templates/be/skeleton/apps/__app__/src/main.ts +3 -9
  95. package/templates/be/skeleton/apps/cli/src/cli.env.ts +13 -0
  96. package/templates/be/skeleton/apps/cli/src/cli.options.ts +6 -8
  97. package/templates/be/skeleton/apps/cli/src/main.ts +3 -2
  98. package/templates/be/skeleton/src/modules/platform/config/env-source.config.ts +6 -6
  99. package/templates/be/skeleton-lite/apps/api/src/api.env.ts +25 -0
  100. package/templates/be/skeleton-lite/apps/api/src/api.options.ts +1 -1
  101. package/templates/be/skeleton-lite/apps/api/src/app.module.ts +6 -42
  102. package/templates/be/skeleton-lite/apps/api/src/compose/platform.ts +45 -0
  103. package/templates/be/skeleton-lite/apps/api/src/main.ts +4 -11
  104. package/templates/be/skeleton-lite/apps/cli/src/cli.env.ts +17 -0
  105. package/upgrade/file-state.mjs +68 -0
  106. package/upgrade/index.mjs +7 -56
  107. package/runtime/scripts/api/git/log.mjs +0 -5
  108. package/templates/app/skeleton-lite/.starciwork/shell/index.yaml +0 -55
@@ -21,18 +21,18 @@ export class Secret {
21
21
  }
22
22
 
23
23
  /**
24
- * The only reader of the process environment. Typed readers name the missing or malformed key in the error; a key
25
- * `<KEY>_FILE` supplies `<KEY>` from a file, so secrets can be mounted instead of exported.
24
+ * The only reader of the process environment. Typed readers name the missing or malformed key in the error.
25
+ * A declared `<KEY>_FILE` takes priority over `<KEY>` and supplies its exact file contents, so secrets can be mounted.
26
26
  */
27
27
  export class EnvSource {
28
28
  constructor(private readonly values: Readonly<Record<string, string | undefined>>) {}
29
29
 
30
- /** Reads the process environment once, resolving every `<KEY>_FILE` into `<KEY>`. */
30
+ /** Reads the process environment once; a declared `<KEY>_FILE` must be readable and never falls back to `<KEY>`. */
31
31
  static fromProcess(): EnvSource {
32
32
  const values: Record<string, string | undefined> = { ...process.env }
33
- for (const [key, path] of Object.entries(process.env)) {
34
- if (key.endsWith(FILE_SUFFIX) && path)
35
- values[key.slice(0, -FILE_SUFFIX.length)] = readFileSync(path, "utf8").trim()
33
+ for (const [key, path] of Object.entries(values)) {
34
+ if (key.endsWith(FILE_SUFFIX) && path !== undefined)
35
+ values[key.slice(0, -FILE_SUFFIX.length)] = readFileSync(path, "utf8")
36
36
  }
37
37
  return new EnvSource(values)
38
38
  }
@@ -0,0 +1,25 @@
1
+ import type { EnvSource } from "@modules/platform/config"
2
+ import { parseServerConfig } from "@modules/platform/config"
3
+ import { parsePrimaryDatabaseConfig } from "@modules/platform/database"
4
+ import { parseHttpSecurityConfig } from "@modules/platform/http-security"
5
+ import { parseSupabaseConfig } from "@modules/integrations/supabase"
6
+
7
+ /** The API environment values required before composition. */
8
+ export interface ApiEnv {
9
+ /** The HTTP listener. */
10
+ readonly server: ReturnType<typeof parseServerConfig>
11
+ /** The origin allowlist and rate limits. */
12
+ readonly httpSecurity: ReturnType<typeof parseHttpSecurityConfig>
13
+ /** The least-privilege PostgreSQL connection to the Supabase project. */
14
+ readonly database: ReturnType<typeof parsePrimaryDatabaseConfig>
15
+ /** Supabase Auth, Storage and token-verification configuration. */
16
+ readonly supabase: ReturnType<typeof parseSupabaseConfig>
17
+ }
18
+
19
+ /** Reads the API environment; missing or invalid values fail boot, and secrets use `_FILE` first. */
20
+ export const readApiEnv = (env: EnvSource): ApiEnv => ({
21
+ server: parseServerConfig(env),
22
+ httpSecurity: parseHttpSecurityConfig(env),
23
+ database: parsePrimaryDatabaseConfig(env),
24
+ supabase: parseSupabaseConfig(env),
25
+ })
@@ -3,7 +3,7 @@ import type { ServerOptions } from "@modules/platform/config"
3
3
  import type { DatabaseConnectionOptions } from "@modules/platform/database"
4
4
  import type { HttpSecurityOptions } from "@modules/platform/http-security"
5
5
 
6
- /** Everything the API needs from its environment, parsed once by `main.ts` and handed to `AppModule.register`. */
6
+ /** Everything the API needs from its environment, parsed by `api.env.ts` and handed to `AppModule.register`. */
7
7
  export interface ApiOptions {
8
8
  /** The HTTP listener. */
9
9
  readonly server: ServerOptions
@@ -2,33 +2,21 @@ import { Module } from "@nestjs/common"
2
2
  import type { DynamicModule } from "@nestjs/common"
3
3
  import { APP_FILTER, APP_GUARD } from "@nestjs/core"
4
4
  import { SystemHealthHttpModule } from "@features/api/system-health"
5
- import { AuthGuard, IDENTITY_ERROR_KINDS, IDENTITY_MESSAGES, IdentityModule } from "@modules/domain/identity"
5
+ import { AuthGuard, IdentityModule } from "@modules/domain/identity"
6
6
  import {
7
7
  SUPABASE_ACCESS_TOKEN_VERIFIER,
8
8
  SUPABASE_ADMIN_CLIENT,
9
- SUPABASE_ERROR_KINDS,
10
9
  createSupabaseAccessTokenVerifier,
11
10
  createSupabaseAdminClient,
12
11
  } from "@modules/integrations/supabase"
13
12
  import { LivenessModule } from "@modules/domain/liveness"
14
- import { ClockModule } from "@modules/platform/clock"
15
- import { CONFIG_ERROR_KINDS } from "@modules/platform/config"
16
- import { CqrsModule } from "@modules/platform/cqrs"
17
- import { DATABASE_ERROR_KINDS, DatabaseModule } from "@modules/platform/database"
18
- import { ERRORS_MESSAGES, ErrorsFilter, ErrorsModule } from "@modules/platform/errors"
19
- import {
20
- HTTP_SECURITY_ERROR_KINDS,
21
- HTTP_SECURITY_MESSAGES,
22
- HttpSecurityModule,
23
- OriginGuard,
24
- RateLimitGuard,
25
- } from "@modules/platform/http-security"
26
- import { I18nModule } from "@modules/platform/i18n"
27
- import { LoggingModule } from "@modules/platform/logging"
13
+ import { ErrorsFilter } from "@modules/platform/errors"
14
+ import { OriginGuard, RateLimitGuard } from "@modules/platform/http-security"
15
+ import { platformModules } from "./compose/platform"
28
16
  import type { ApiOptions } from "./api.options"
29
17
 
30
18
  @Module({})
31
- /** Composition root of the API: every capability is registered once and the three guards run in a fixed order. */
19
+ /** Composition root of the API: it spreads the composition groups of `compose/`, and the three guards run in a fixed order. */
32
20
  export class AppModule {
33
21
  /** Builds the module from options parsed once at boot; nothing here reads the environment. */
34
22
  static register(options: ApiOptions): DynamicModule {
@@ -36,31 +24,7 @@ export class AppModule {
36
24
  return {
37
25
  module: AppModule,
38
26
  imports: [
39
- ClockModule.register({ isGlobal: true }),
40
- LoggingModule.register({ isGlobal: true }),
41
- I18nModule.register({
42
- isGlobal: true,
43
- bundles: [ERRORS_MESSAGES, HTTP_SECURITY_MESSAGES, IDENTITY_MESSAGES],
44
- }),
45
- ErrorsModule.register({
46
- isGlobal: true,
47
- kinds: [
48
- CONFIG_ERROR_KINDS,
49
- DATABASE_ERROR_KINDS,
50
- HTTP_SECURITY_ERROR_KINDS,
51
- IDENTITY_ERROR_KINDS,
52
- SUPABASE_ERROR_KINDS,
53
- ],
54
- }),
55
- CqrsModule.register({ isGlobal: true }),
56
- HttpSecurityModule.register({
57
- isGlobal: true,
58
- ...options.httpSecurity,
59
- }),
60
- DatabaseModule.register({
61
- isGlobal: true,
62
- connections: [options.database],
63
- }),
27
+ ...platformModules(options),
64
28
  IdentityModule.register({ isGlobal: true, verifyAccessToken }),
65
29
  LivenessModule.register({ isGlobal: true }),
66
30
  SystemHealthHttpModule,
@@ -0,0 +1,45 @@
1
+ import type { DynamicModule } from "@nestjs/common"
2
+ import { IDENTITY_ERROR_KINDS, IDENTITY_MESSAGES } from "@modules/domain/identity"
3
+ import { SUPABASE_ERROR_KINDS } from "@modules/integrations/supabase"
4
+ import { ClockModule } from "@modules/platform/clock"
5
+ import { CONFIG_ERROR_KINDS } from "@modules/platform/config"
6
+ import { CqrsModule } from "@modules/platform/cqrs"
7
+ import { DATABASE_ERROR_KINDS, DatabaseModule } from "@modules/platform/database"
8
+ import { ERRORS_MESSAGES, ErrorsModule } from "@modules/platform/errors"
9
+ import { HTTP_SECURITY_ERROR_KINDS, HTTP_SECURITY_MESSAGES, HttpSecurityModule } from "@modules/platform/http-security"
10
+ import { I18nModule } from "@modules/platform/i18n"
11
+ import { LoggingModule } from "@modules/platform/logging"
12
+ import type { ApiOptions } from "../api.options"
13
+
14
+ /**
15
+ * The platform area of the API: every platform capability, registered once and app-wide. A composition group holds
16
+ * one area's module list and nothing else (no logic, no environment read); `app.module.ts` spreads it into its
17
+ * imports.
18
+ */
19
+ export const platformModules = (options: ApiOptions): Array<DynamicModule> => [
20
+ ClockModule.register({ isGlobal: true }),
21
+ LoggingModule.register({ isGlobal: true }),
22
+ I18nModule.register({
23
+ isGlobal: true,
24
+ bundles: [ERRORS_MESSAGES, HTTP_SECURITY_MESSAGES, IDENTITY_MESSAGES],
25
+ }),
26
+ ErrorsModule.register({
27
+ isGlobal: true,
28
+ kinds: [
29
+ CONFIG_ERROR_KINDS,
30
+ DATABASE_ERROR_KINDS,
31
+ HTTP_SECURITY_ERROR_KINDS,
32
+ IDENTITY_ERROR_KINDS,
33
+ SUPABASE_ERROR_KINDS,
34
+ ],
35
+ }),
36
+ CqrsModule.register({ isGlobal: true }),
37
+ HttpSecurityModule.register({
38
+ isGlobal: true,
39
+ ...options.httpSecurity,
40
+ }),
41
+ DatabaseModule.register({
42
+ isGlobal: true,
43
+ connections: [options.database],
44
+ }),
45
+ ]
@@ -1,22 +1,15 @@
1
1
  import { NestFactory } from "@nestjs/core"
2
2
  import { SystemClockService } from "@modules/platform/clock"
3
- import { EnvSource, parseServerConfig } from "@modules/platform/config"
4
- import { parsePrimaryDatabaseConfig } from "@modules/platform/database"
5
- import { parseHttpSecurityConfig, RequestValidationService } from "@modules/platform/http-security"
3
+ import { EnvSource } from "@modules/platform/config"
4
+ import { RequestValidationService } from "@modules/platform/http-security"
6
5
  import { createJsonLogger, LoggingLogEvent } from "@modules/platform/logging"
7
- import { parseSupabaseConfig } from "@modules/integrations/supabase"
6
+ import { readApiEnv } from "./api.env"
8
7
  import { AppModule } from "./app.module"
9
- import type { ApiOptions } from "./api.options"
10
8
 
11
9
  /** Reads the environment once, composes the API from parsed options and serves until a shutdown signal. */
12
10
  const bootstrap = async (): Promise<void> => {
13
11
  const env = EnvSource.fromProcess()
14
- const options: ApiOptions = {
15
- server: parseServerConfig(env),
16
- httpSecurity: parseHttpSecurityConfig(env),
17
- database: parsePrimaryDatabaseConfig(env),
18
- supabase: parseSupabaseConfig(env),
19
- }
12
+ const options = readApiEnv(env)
20
13
  const app = await NestFactory.create(AppModule.register(options), { rawBody: true })
21
14
  app.enableCors({ origin: [...options.httpSecurity.allowedOrigins] })
22
15
  app.enableShutdownHooks()
@@ -0,0 +1,17 @@
1
+ import type { EnvSource, Secret } from "@modules/platform/config"
2
+ import { parsePrimaryDatabaseConfig } from "@modules/platform/database"
3
+
4
+ /** The cli's primary database configuration before command composition. */
5
+ export interface CliEnv {
6
+ /** The logical connection name declared in `hfs.json`. */
7
+ readonly name: string
8
+ /** The Supabase PostgreSQL provider. */
9
+ readonly provider: "supabase"
10
+ /** The least-privilege database connection URL, read through the secret source. */
11
+ readonly url: Secret
12
+ /** The isolated PostgreSQL schema. */
13
+ readonly schema?: string
14
+ }
15
+
16
+ /** Reads the cli environment with no defaults; database secret readers prefer mounted files. */
17
+ export const readCliEnv = (env: EnvSource): CliEnv => parsePrimaryDatabaseConfig(env)
@@ -0,0 +1,68 @@
1
+ // Authored-file preservation, snapshots and rollback for an additive edition upgrade.
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { dockerfilePath } from "../runtime/scripts/hfs/rules/docker.mjs";
5
+
6
+ /** App-owned additions are preserved; only declared regular optional front-end images receive that treatment. */
7
+ export function authoredAdditionReasons(declaration, source) {
8
+ const reasons = new Map([[
9
+ ".starcistacks/application-stacks.yaml",
10
+ "The app already declares its stacks; the declaration stays as the app wrote it.",
11
+ ]]);
12
+ for (const app of declaration.sides.fe.apps) {
13
+ const file = dockerfilePath("fe", app.name);
14
+ if (source.hasImage(file)) reasons.set(file,
15
+ "The lite app already authors this optional front-end image; its Dockerfile stays as the app wrote it.");
16
+ }
17
+ return reasons;
18
+ }
19
+
20
+ export function remember(snapshot, target) {
21
+ if (snapshot.has(target)) return;
22
+ snapshot.set(
23
+ target,
24
+ fs.existsSync(target) && fs.statSync(target).isFile()
25
+ ? fs.readFileSync(target)
26
+ : null,
27
+ );
28
+ }
29
+
30
+ export function ensureParent(root, target, directories) {
31
+ const missing = [];
32
+ for (
33
+ let at = path.dirname(target);
34
+ at.startsWith(root) && at !== root && !fs.existsSync(at);
35
+ at = path.dirname(at)
36
+ )
37
+ missing.push(at);
38
+ for (const directory of missing.reverse()) {
39
+ fs.mkdirSync(directory);
40
+ directories.push(directory);
41
+ }
42
+ }
43
+
44
+ export function writeFile(root, file, content, snapshot, directories) {
45
+ const target = path.join(root, ...file.split("/"));
46
+ remember(snapshot, target);
47
+ ensureParent(root, target, directories);
48
+ fs.writeFileSync(target, content);
49
+ }
50
+
51
+ export function restore(snapshot, directories) {
52
+ for (const [target, content] of [...snapshot.entries()].reverse()) {
53
+ if (content === null) {
54
+ if (fs.existsSync(target) && fs.statSync(target).isFile())
55
+ fs.unlinkSync(target);
56
+ } else {
57
+ fs.mkdirSync(path.dirname(target), { recursive: true });
58
+ fs.writeFileSync(target, content);
59
+ }
60
+ }
61
+ for (const directory of [...directories].reverse()) {
62
+ try {
63
+ fs.rmdirSync(directory);
64
+ } catch {
65
+ /* It is non-empty or already absent: preserve it. */
66
+ }
67
+ }
68
+ }
package/upgrade/index.mjs CHANGED
@@ -6,6 +6,7 @@
6
6
  // restored before the refusal is returned.
7
7
  import fs from "node:fs";
8
8
  import path from "node:path";
9
+ import { authoredAdditionReasons, remember, ensureParent, writeFile, restore } from "./file-state.mjs";
9
10
  import { parseYaml } from "../runtime/engine/yaml.mjs";
10
11
  import {
11
12
  loadSlotManifest,
@@ -229,9 +230,9 @@ function additions(root, full, hadCli, manifest) {
229
230
  const variables = { project: full.project, app: "cli", appPascal: "Cli" };
230
231
  const files = [
231
232
  ...templateFiles("app/upgrade-full", "", variables),
232
- ...(fs.existsSync(path.join(root, ".starciwork", "index.yaml"))
233
- ? []
234
- : scaffoldWorkSeeds(full.project).filter((file) => !fs.existsSync(path.join(root, file.path)))),
233
+ ...scaffoldWorkSeeds(full.project).filter((file) =>
234
+ !fs.existsSync(path.join(root, file.path))
235
+ && (file.path === ".starciwork/workspace.yaml" || !fs.existsSync(path.join(root, ".starciwork", "index.yaml")))),
235
236
  ...(!hadCli
236
237
  ? [
237
238
  ...STANDARD_CLI_FILES.map((file) => ({
@@ -270,8 +271,6 @@ function sourceWithAdditions(root, additions) {
270
271
  };
271
272
  }
272
273
 
273
- // Files the app authors itself: the upgrade never overwrites them and keeps an existing one (CONTEXT: declarations are preserved).
274
- const APP_OWNED = new Set([".starcistacks/application-stacks.yaml"]);
275
274
  const publicStep = ({ op, path: file, why }) => ({ op, path: file, why });
276
275
 
277
276
  async function model({ root, to, presets, manifest }) {
@@ -286,6 +285,7 @@ async function model({ root, to, presets, manifest }) {
286
285
  );
287
286
  const full = fullEditionDeclaration(declaration, manifest);
288
287
  const desired = additions(root, full, hadCli, manifest);
288
+ const authored = authoredAdditionReasons(declaration, appSource(root));
289
289
  const pins = parseYaml(fs.readFileSync(PINS_FILE, "utf8")).pins;
290
290
  const upgradedPackage = pinnedPackage(root, pins);
291
291
  const resolvedPresets = presets ?? UPGRADE_PRESETS;
@@ -357,8 +357,8 @@ async function model({ root, to, presets, manifest }) {
357
357
  for (const file of desired) {
358
358
  const target = path.join(root, ...file.path.split("/"));
359
359
  if (fs.existsSync(target)) {
360
- if (APP_OWNED.has(file.path)) {
361
- steps.push({ op: "keep", path: file.path, why: "The app already declares its stacks; the declaration stays as the app wrote it.", kind: "keep" });
360
+ if (authored.has(file.path)) {
361
+ steps.push({ op: "keep", path: file.path, why: authored.get(file.path), kind: "keep" });
362
362
  continue;
363
363
  }
364
364
  if (!sameText(root, file.path, file.content))
@@ -400,55 +400,6 @@ async function model({ root, to, presets, manifest }) {
400
400
  return { full, managed: managedWithoutPackage, steps, dependenciesChanged };
401
401
  }
402
402
 
403
- function remember(snapshot, target) {
404
- if (snapshot.has(target)) return;
405
- snapshot.set(
406
- target,
407
- fs.existsSync(target) && fs.statSync(target).isFile()
408
- ? fs.readFileSync(target)
409
- : null,
410
- );
411
- }
412
-
413
- function ensureParent(root, target, directories) {
414
- const missing = [];
415
- for (
416
- let at = path.dirname(target);
417
- at.startsWith(root) && at !== root && !fs.existsSync(at);
418
- at = path.dirname(at)
419
- )
420
- missing.push(at);
421
- for (const directory of missing.reverse()) {
422
- fs.mkdirSync(directory);
423
- directories.push(directory);
424
- }
425
- }
426
-
427
- function writeFile(root, file, content, snapshot, directories) {
428
- const target = path.join(root, ...file.split("/"));
429
- remember(snapshot, target);
430
- ensureParent(root, target, directories);
431
- fs.writeFileSync(target, content);
432
- }
433
-
434
- function restore(snapshot, directories) {
435
- for (const [target, content] of [...snapshot.entries()].reverse()) {
436
- if (content === null) {
437
- if (fs.existsSync(target) && fs.statSync(target).isFile())
438
- fs.unlinkSync(target);
439
- } else {
440
- fs.mkdirSync(path.dirname(target), { recursive: true });
441
- fs.writeFileSync(target, content);
442
- }
443
- }
444
- for (const directory of [...directories].reverse()) {
445
- try {
446
- fs.rmdirSync(directory);
447
- } catch {
448
- /* It is non-empty or already absent: preserve it. */
449
- }
450
- }
451
- }
452
403
 
453
404
  /**
454
405
  * Plans or applies the lite -> full migration. Returns the ordered public plan (`{op,path,why}[]`) in both modes.
@@ -1,5 +0,0 @@
1
- // log.mjs — `git log <args>`: the commit history in a caller-chosen format.
2
- import { runGit } from './lib.mjs';
3
-
4
- /** The spawn result {status, stdout, stderr, error} of `git log ...args`; options are runGit's (cwd or dir, config, timeout, maxBuffer, input, encoding, env, git). */
5
- export const log = (args = [], options = {}) => runGit(['log', ...args], options);
@@ -1,55 +0,0 @@
1
- schema: work/layout-tree@1
2
- id: shell
3
- kind: shell
4
- state: todo
5
- rev: 1
6
- origin: planned
7
- productLocale:
8
- default: vi
9
- fallback: vi
10
- locales:
11
- - vi
12
- breakpoints:
13
- - name: desktop
14
- width: 1440
15
- height: 900
16
- - name: mobile
17
- width: 390
18
- height: 844
19
- themes:
20
- - light
21
- apps:
22
- - name: web
23
- root: fe/apps/web
24
- appDir: fe/apps/web/src/app
25
- framework: next-app-router
26
- localeParam: locale
27
- nodes:
28
- - id: /
29
- parent: null
30
- segment: /
31
- segmentKind: root
32
- url: /
33
- origin: planned
34
- - id: /[locale]
35
- parent: /
36
- segment: "[locale]"
37
- segmentKind: dynamic
38
- url: /[locale]
39
- origin: planned
40
- files:
41
- layout:
42
- path: fe/apps/web/src/app/[locale]/layout.tsx
43
- page:
44
- path: fe/apps/web/src/app/[locale]/page.tsx
45
- loading:
46
- path: fe/apps/web/src/app/[locale]/loading.tsx
47
- error:
48
- path: fe/apps/web/src/app/[locale]/error.tsx
49
- not-found:
50
- path: fe/apps/web/src/app/[locale]/not-found.tsx
51
- layout:
52
- component: AppLayout
53
- chrome: visible
54
- state: todo
55
- rev: 1