primitive-admin 1.1.0-alpha.65 → 1.1.0-alpha.67

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 (106) hide show
  1. package/README.md +6 -7
  2. package/assets/skill/skills/primitive-platform/SKILL.md +28 -9
  3. package/dist/bin/primitive.js +4 -3
  4. package/dist/bin/primitive.js.map +1 -1
  5. package/dist/src/commands/apps.js +14 -5
  6. package/dist/src/commands/apps.js.map +1 -1
  7. package/dist/src/commands/config.js +11 -11
  8. package/dist/src/commands/config.js.map +1 -1
  9. package/dist/src/commands/databases.js +15 -3
  10. package/dist/src/commands/databases.js.map +1 -1
  11. package/dist/src/commands/email-templates.js +5 -5
  12. package/dist/src/commands/env.d.ts +12 -1
  13. package/dist/src/commands/env.js +98 -25
  14. package/dist/src/commands/env.js.map +1 -1
  15. package/dist/src/commands/init.d.ts +9 -1
  16. package/dist/src/commands/init.js +810 -218
  17. package/dist/src/commands/init.js.map +1 -1
  18. package/dist/src/commands/prompts.js +5 -2
  19. package/dist/src/commands/prompts.js.map +1 -1
  20. package/dist/src/commands/sync-app-settings.d.ts +83 -55
  21. package/dist/src/commands/sync-app-settings.js +154 -200
  22. package/dist/src/commands/sync-app-settings.js.map +1 -1
  23. package/dist/src/commands/sync.d.ts +362 -29
  24. package/dist/src/commands/sync.js +2734 -351
  25. package/dist/src/commands/sync.js.map +1 -1
  26. package/dist/src/commands/workflows.js +19 -5
  27. package/dist/src/commands/workflows.js.map +1 -1
  28. package/dist/src/lib/api-client.d.ts +21 -1
  29. package/dist/src/lib/api-client.js +23 -2
  30. package/dist/src/lib/api-client.js.map +1 -1
  31. package/dist/src/lib/app-settings-descriptor.d.ts +150 -17
  32. package/dist/src/lib/app-settings-descriptor.js +344 -31
  33. package/dist/src/lib/app-settings-descriptor.js.map +1 -1
  34. package/dist/src/lib/config-object-descriptor.js +4 -1
  35. package/dist/src/lib/config-object-descriptor.js.map +1 -1
  36. package/dist/src/lib/config-payload.js +14 -3
  37. package/dist/src/lib/config-payload.js.map +1 -1
  38. package/dist/src/lib/config-surface.d.ts +37 -1
  39. package/dist/src/lib/config-surface.js +118 -0
  40. package/dist/src/lib/config-surface.js.map +1 -1
  41. package/dist/src/lib/env-resolver-core.d.ts +147 -0
  42. package/dist/src/lib/env-resolver-core.js +265 -0
  43. package/dist/src/lib/env-resolver-core.js.map +1 -0
  44. package/dist/src/lib/env-resolver.d.ts +28 -6
  45. package/dist/src/lib/env-resolver.js +44 -32
  46. package/dist/src/lib/env-resolver.js.map +1 -1
  47. package/dist/src/lib/generated-config-surfaces.d.ts +71 -0
  48. package/dist/src/lib/generated-config-surfaces.js +264 -69
  49. package/dist/src/lib/generated-config-surfaces.js.map +1 -1
  50. package/dist/src/lib/init-adopt.d.ts +16 -0
  51. package/dist/src/lib/init-adopt.js +34 -0
  52. package/dist/src/lib/init-adopt.js.map +1 -0
  53. package/dist/src/lib/init-assets.d.ts +39 -0
  54. package/dist/src/lib/init-assets.js +97 -0
  55. package/dist/src/lib/init-assets.js.map +1 -0
  56. package/dist/src/lib/init-config.d.ts +21 -4
  57. package/dist/src/lib/init-config.js +45 -8
  58. package/dist/src/lib/init-config.js.map +1 -1
  59. package/dist/src/lib/init-plan.d.ts +80 -0
  60. package/dist/src/lib/init-plan.js +95 -0
  61. package/dist/src/lib/init-plan.js.map +1 -0
  62. package/dist/src/lib/init-production-env.d.ts +48 -0
  63. package/dist/src/lib/init-production-env.js +59 -0
  64. package/dist/src/lib/init-production-env.js.map +1 -0
  65. package/dist/src/lib/init-schema.d.ts +74 -0
  66. package/dist/src/lib/init-schema.js +358 -0
  67. package/dist/src/lib/init-schema.js.map +1 -0
  68. package/dist/src/lib/init-xcode.d.ts +33 -0
  69. package/dist/src/lib/init-xcode.js +114 -0
  70. package/dist/src/lib/init-xcode.js.map +1 -0
  71. package/dist/src/lib/integration-request-config.d.ts +30 -0
  72. package/dist/src/lib/integration-request-config.js +145 -0
  73. package/dist/src/lib/integration-request-config.js.map +1 -0
  74. package/dist/src/lib/local-state.d.ts +55 -0
  75. package/dist/src/lib/local-state.js +167 -0
  76. package/dist/src/lib/local-state.js.map +1 -0
  77. package/dist/src/lib/platform-owned.d.ts +12 -0
  78. package/dist/src/lib/platform-owned.js +21 -0
  79. package/dist/src/lib/platform-owned.js.map +1 -1
  80. package/dist/src/lib/project-config.js +2 -4
  81. package/dist/src/lib/project-config.js.map +1 -1
  82. package/dist/src/lib/resolve-init-server.d.ts +0 -7
  83. package/dist/src/lib/resolve-init-server.js +2 -19
  84. package/dist/src/lib/resolve-init-server.js.map +1 -1
  85. package/dist/src/lib/resolve-platform.d.ts +43 -14
  86. package/dist/src/lib/resolve-platform.js +74 -12
  87. package/dist/src/lib/resolve-platform.js.map +1 -1
  88. package/dist/src/lib/server-text-normalization.d.ts +51 -0
  89. package/dist/src/lib/server-text-normalization.js +90 -0
  90. package/dist/src/lib/server-text-normalization.js.map +1 -0
  91. package/dist/src/lib/server-url.d.ts +22 -0
  92. package/dist/src/lib/server-url.js +33 -0
  93. package/dist/src/lib/server-url.js.map +1 -0
  94. package/dist/src/lib/sync-resource-types.d.ts +8 -0
  95. package/dist/src/lib/sync-resource-types.js +19 -2
  96. package/dist/src/lib/sync-resource-types.js.map +1 -1
  97. package/dist/src/lib/sync-selectors.js +4 -4
  98. package/dist/src/lib/sync-selectors.js.map +1 -1
  99. package/dist/src/lib/template.d.ts +50 -1
  100. package/dist/src/lib/template.js +59 -23
  101. package/dist/src/lib/template.js.map +1 -1
  102. package/dist/src/lib/test-case-keys.d.ts +29 -0
  103. package/dist/src/lib/test-case-keys.js +55 -0
  104. package/dist/src/lib/test-case-keys.js.map +1 -0
  105. package/dist/src/types/index.d.ts +40 -8
  106. package/package.json +2 -2
@@ -1,7 +1,14 @@
1
- import { confirm, input, select } from "@inquirer/prompts";
2
- import { appendFileSync, existsSync, readFileSync, writeFileSync } from "fs";
3
- import { basename, join, resolve } from "path";
4
- import { CURRENT_CONFIG_VERSION, PROJECT_CONFIG_DIR, PROJECT_CONFIG_FILENAME, PROJECT_CONFIG_DISPLAY_NAME, saveProjectConfig, loadProjectConfigFromPath, } from "../lib/project-config.js";
1
+ import { checkbox, confirm, input, select } from "@inquirer/prompts";
2
+ import { appendFileSync, existsSync, readFileSync, readdirSync, writeFileSync, } from "fs";
3
+ import { basename, join, relative, resolve } from "path";
4
+ import { CURRENT_CONFIG_VERSION, PROJECT_CONFIG_DIR, PROJECT_CONFIG_FILENAME, PROJECT_CONFIG_DISPLAY_NAME, findProjectRoot, saveProjectConfig, loadProjectConfigFromPath, } from "../lib/project-config.js";
5
+ import { resolveAdoptedEnvironment } from "../lib/init-adopt.js";
6
+ import { CANONICAL_SCHEMA_HEADER, FALLBACK_STARTER_SCHEMA, platformLabel, rootAgentsMarkdown, } from "../lib/init-assets.js";
7
+ import { checkInitGuards, planInit } from "../lib/init-plan.js";
8
+ import { resolveProductionRuntime } from "../lib/init-production-env.js";
9
+ import { checkClientWiring, composeSchemaContent, declaredModelNames, findClientSchema, wireClientToSchema, writeCanonicalSchema, } from "../lib/init-schema.js";
10
+ import { declareSharedProjectConfig } from "../lib/init-xcode.js";
11
+ import { getExplicitEnvName } from "../lib/env-resolver.js";
5
12
  import { ApiClient } from "../lib/api-client.js";
6
13
  import { browserLogin } from "../lib/auth-flow.js";
7
14
  import { loadCredentials, setCurrentApp } from "../lib/config.js";
@@ -9,13 +16,14 @@ import { isProjectMode, seedCredentialsForEnvInProject, } from "../lib/credentia
9
16
  import { getCurrentEnvironment, getProjectConfig } from "../lib/env-resolver.js";
10
17
  import { normalizeServerUrl } from "../lib/channel.js";
11
18
  import { INIT_CONFIG_FILENAME, loadInitConfig, parseInitConfigToml, resolveSkipInstall, resolveUseExistingAppId, validateInitConfig, } from "../lib/init-config.js";
12
- import { resolvePlatform, } from "../lib/resolve-platform.js";
13
- import { resolveInitServer, validateServerUrl, } from "../lib/resolve-init-server.js";
19
+ import { parsePlatformList, resolvePlatforms, } from "../lib/resolve-platform.js";
20
+ import { resolveInitServer } from "../lib/resolve-init-server.js";
21
+ import { validateServerUrl } from "../lib/server-url.js";
14
22
  import { DEFAULT_DEV_PORT, resolveDevPort, validateDevPort, } from "../lib/resolve-init-dev-port.js";
15
- import { DEFAULT_SERVER_URL } from "../lib/constants.js";
23
+ import { DEFAULT_SERVER_URL, PRODUCTION_SERVER_URL } from "../lib/constants.js";
16
24
  import { divider, error, info, keyValue, success, warn, } from "../lib/output.js";
17
25
  import { installCommand, PRIMITIVE_CLI_PACKAGE } from "../lib/package-manager.js";
18
- import { gitInitialCommit, gitInitRepo, installPnpmGlobally, installPrimitiveCli, isDirectoryEmpty, isPnpmInstalled, isPortFree, isPrimitiveCliInstalled, isXcodeInstalled, iosTemplateUrl, vueTemplateUrl, resolveTemplateBranch, prepareTemplate, runPnpmInstall, runSwiftPackageResolve, updateEnvFile, updateEnvPort, updatePrimitiveJson, updateViteConfig, } from "../lib/template.js";
26
+ import { gitInitialCommit, gitInitRepo, installPnpmGlobally, installPrimitiveCli, isDirectoryEmpty, isPnpmInstalled, isPortFree, isPrimitiveCliInstalled, isXcodeInstalled, iosTemplateUrl, vueTemplateUrl, resolveTemplateBranch, prepareTemplate, runPnpmInstall, runSwiftPackageResolve, updateEnvFile, updateEnvPort, configurePrimitiveJson, updateViteConfig, } from "../lib/template.js";
19
27
  /**
20
28
  * The .gitignore lines `init` writes for Primitive CLI local state.
21
29
  *
@@ -32,54 +40,23 @@ import { gitInitialCommit, gitInitRepo, installPnpmGlobally, installPrimitiveCli
32
40
  */
33
41
  export const GITIGNORE_LOCAL_STATE_ENTRIES = [
34
42
  ".primitive/credentials.json",
43
+ // This machine's Primitive environment selection (`primitive env use`).
44
+ // The committed `defaultEnvironment` is the team default; the selection is
45
+ // per-machine and must never be committable (#2873).
46
+ ".primitive/local.json",
35
47
  ".primitive/sync/**/.primitive-sync.json",
36
48
  ".primitive/sync-backups/",
37
49
  ".snapshots/",
38
50
  "config-snapshots/",
39
51
  ];
40
52
  /**
41
- * Writes .primitive/config.json into the project directory and ensures the
42
- * .gitignore entry for local state exists. Safe to call in a directory that
43
- * already has a config — in that case we add/update the "dev" environment
44
- * instead of overwriting the file.
53
+ * Ensures the project root's .gitignore lists the Primitive CLI's local state.
54
+ *
55
+ * Split out of `scaffoldProjectConfig` because a run that ADOPTS an existing
56
+ * project (#2890) must not rewrite its config, but still has to make sure the
57
+ * per-machine state a new client's environment produces cannot be committed.
45
58
  */
46
- async function scaffoldProjectConfig(opts) {
47
- const configPath = join(opts.targetDir, PROJECT_CONFIG_DIR, PROJECT_CONFIG_FILENAME);
48
- let config;
49
- if (existsSync(configPath)) {
50
- // Respect an existing config: merge in the new dev env if missing.
51
- try {
52
- config = loadProjectConfigFromPath(configPath);
53
- }
54
- catch {
55
- // Existing file is broken — don't clobber it; just warn and skip.
56
- warn(`${PROJECT_CONFIG_DISPLAY_NAME} exists but is invalid. Skipping project config scaffold.`);
57
- return;
58
- }
59
- if (!config.environments.dev) {
60
- config.environments.dev = {
61
- apiUrl: opts.serverUrl,
62
- appId: opts.appId,
63
- appName: opts.appName,
64
- };
65
- if (!config.defaultEnvironment)
66
- config.defaultEnvironment = "dev";
67
- }
68
- }
69
- else {
70
- config = {
71
- version: CURRENT_CONFIG_VERSION,
72
- defaultEnvironment: "dev",
73
- environments: {
74
- dev: {
75
- apiUrl: opts.serverUrl,
76
- appId: opts.appId,
77
- appName: opts.appName,
78
- },
79
- },
80
- };
81
- }
82
- saveProjectConfig(configPath, config);
59
+ export function ensureGitignoreLocalState(targetDir) {
83
60
  // Ensure .primitive/ local state (credentials, per-machine sync state) is
84
61
  // gitignored. The config.json itself lives in .primitive/ too, but we
85
62
  // gitignore only the sensitive / per-machine artifacts — config.json and
@@ -92,7 +69,7 @@ async function scaffoldProjectConfig(opts) {
92
69
  // which incorrectly hid the TOML export tree from `git status`. If we see
93
70
  // that legacy entry, replace it with the targeted pattern in-place so
94
71
  // users who re-run init pick up the fix.
95
- const gitignorePath = join(opts.targetDir, ".gitignore");
72
+ const gitignorePath = join(targetDir, ".gitignore");
96
73
  const LEGACY_SYNC_IGNORE = ".primitive/sync/";
97
74
  const gitignoreEntries = [...GITIGNORE_LOCAL_STATE_ENTRIES];
98
75
  let linesToAdd = [];
@@ -137,6 +114,51 @@ async function scaffoldProjectConfig(opts) {
137
114
  // Non-fatal
138
115
  }
139
116
  }
117
+ }
118
+ /**
119
+ * Writes .primitive/config.json into the project directory and ensures the
120
+ * .gitignore entry for local state exists. Safe to call in a directory that
121
+ * already has a config — in that case we add/update the "dev" environment
122
+ * instead of overwriting the file.
123
+ */
124
+ async function scaffoldProjectConfig(opts) {
125
+ const configPath = join(opts.targetDir, PROJECT_CONFIG_DIR, PROJECT_CONFIG_FILENAME);
126
+ let config;
127
+ if (existsSync(configPath)) {
128
+ // Respect an existing config: merge in the new dev env if missing.
129
+ try {
130
+ config = loadProjectConfigFromPath(configPath);
131
+ }
132
+ catch {
133
+ // Existing file is broken — don't clobber it; just warn and skip.
134
+ warn(`${PROJECT_CONFIG_DISPLAY_NAME} exists but is invalid. Skipping project config scaffold.`);
135
+ return;
136
+ }
137
+ if (!config.environments.dev) {
138
+ config.environments.dev = {
139
+ apiUrl: opts.serverUrl,
140
+ appId: opts.appId,
141
+ appName: opts.appName,
142
+ };
143
+ if (!config.defaultEnvironment)
144
+ config.defaultEnvironment = "dev";
145
+ }
146
+ }
147
+ else {
148
+ config = {
149
+ version: CURRENT_CONFIG_VERSION,
150
+ defaultEnvironment: "dev",
151
+ environments: {
152
+ dev: {
153
+ apiUrl: opts.serverUrl,
154
+ appId: opts.appId,
155
+ appName: opts.appName,
156
+ },
157
+ },
158
+ };
159
+ }
160
+ saveProjectConfig(configPath, config);
161
+ ensureGitignoreLocalState(opts.targetDir);
140
162
  success(`Wrote ${PROJECT_CONFIG_DISPLAY_NAME} with dev environment`);
141
163
  }
142
164
  /**
@@ -257,25 +279,41 @@ export function registerInitCommand(program) {
257
279
  .option("-s, --server <url>", "Server URL (default: current CLI server or https://primitiveapi.com)")
258
280
  .option("--skip-install", "Skip dependency installation")
259
281
  .option("--config <path>", `Path to an init config file for non-interactive setup (default: auto-discover ${INIT_CONFIG_FILENAME} in the current directory)`)
260
- .option("-p, --platform <platform>", "Target platform: web or ios (SwiftUI). Omit to be prompted interactively.")
282
+ .option("-p, --platform <platforms>", "Client platform(s) to scaffold: web, ios (SwiftUI), or both as a comma-separated list (web,ios). Omit to be prompted interactively.")
261
283
  .addHelpText("after", `
262
284
  Examples:
263
285
  $ primitive init my-app # Create web app in ./my-app
264
286
  $ primitive init # Interactive: prompt for directory and app name
265
287
  $ primitive init my-app --platform ios # Create an iOS (SwiftUI) app
288
+ $ primitive init my-app --platform web,ios # One app, a web client and an iOS client
289
+ $ primitive init ios --platform ios # Add an iOS client to the app of the surrounding project
266
290
  $ primitive init . --use-existing-app-id 01HXY... # Use existing app in current dir
267
291
  $ primitive init my-app --skip-install # Skip dependency install
268
292
  $ primitive init --config .primitive-init.toml # Non-interactive from config file
269
293
 
294
+ One app, several clients:
295
+ Selecting more than one platform scaffolds them side by side under one project:
296
+ each client in its own directory (web/, ios/), with .primitive/config.json, the
297
+ git repository and the shared models/models.toml at the root. Run inside a
298
+ directory that already has an ancestor .primitive/config.json and init ADDS the
299
+ client to that app instead: it reuses the selected environment's app, writes no
300
+ nested .primitive/ or .git/, makes no commit, and leaves the files for you to
301
+ review and commit in the outer repository.
302
+
270
303
  Non-interactive mode:
271
304
  Create a ${INIT_CONFIG_FILENAME} file (or pass --config <path>) to skip all prompts.
272
305
  The config file uses TOML format:
273
306
 
274
- action = "create" # "create" or "use-existing"
307
+ action = "create" # "create", "use-existing", or "add-client"
275
308
  app_name = "My App" # Required for action = "create"
276
309
  app_id = "01HXY..." # Required for action = "use-existing"
277
310
  dir = "my-app" # Project directory (optional)
278
- platform = "web" # "web" or "ios" (optional, defaults to web)
311
+ platform = "web" # "web", "ios", "web,ios", or ["web", "ios"]
312
+ # (optional, defaults to web)
313
+ promote_schema = false # Only with action = "add-client". Consent to move
314
+ # an existing single-client repo's models.toml to
315
+ # <project root>/models/models.toml so every client
316
+ # reads one copy.
279
317
  access_mode = "invite-only" # "public", "domain", or "invite-only" (optional)
280
318
  dev_port = 5173 # Dev server port (optional). Set it to skip the
281
319
  # port prompt: init pins the port in
@@ -299,22 +337,28 @@ Non-interactive mode:
299
337
  overwrite = false # Overwrite non-empty target directory (optional)
300
338
 
301
339
  This command will:
302
- 1. Download the Primitive app template (web or Apple SwiftUI)
303
- 2. Prompt for an app name and create it on the Primitive server (or use existing)
304
- 3. Configure the project with your app ID
340
+ 1. Download a Primitive app template for each selected platform (web, Apple SwiftUI)
341
+ 2. Prompt for an app name and create it on the Primitive server (or use existing,
342
+ or adopt the app of the project it is run inside)
343
+ 3. Configure the project with your app ID and point every client at one model schema
305
344
  4. Install dependencies (pnpm for web, swift package resolve for Apple)
306
345
  `)
307
346
  .action(handleInit);
308
347
  }
309
348
  async function handleInit(projectDirArg, options) {
310
349
  // Validate an explicitly-passed `--platform` flag up front. Only `web` and
311
- // `ios` are supported (#1009 removed `macos`). An omitted flag is
312
- // `undefined` (the commander default was dropped) and is resolved below.
313
- if (options.platform !== undefined &&
314
- options.platform !== "web" &&
315
- options.platform !== "ios") {
316
- error(`Unknown platform "${options.platform}". Supported platforms: web, ios`);
317
- process.exit(1);
350
+ // `ios` are supported (#1009 removed `macos`), and the flag names a LIST —
351
+ // `--platform web,ios` scaffolds both clients of one app (#2890). An omitted
352
+ // flag is `undefined` (the commander default was dropped) and is resolved
353
+ // below.
354
+ let flagPlatforms;
355
+ if (options.platform !== undefined) {
356
+ const parsed = parsePlatformList(options.platform);
357
+ if (!parsed.ok) {
358
+ error(parsed.error);
359
+ process.exit(1);
360
+ }
361
+ flagPlatforms = parsed.platforms;
318
362
  }
319
363
  // `initConfig` is the parsed-and-validated `primitive-init.toml` for
320
364
  // non-interactive runs (CI, scripted scaffolding). When non-null, the
@@ -333,23 +377,30 @@ async function handleInit(projectDirArg, options) {
333
377
  // (d) non-TTY, no flag/cfg -> fall back to web (no hang/crash)
334
378
  // The prompt is TTY-guarded inside `resolvePlatform` so a piped/CI stdin
335
379
  // never triggers the `@inquirer/prompts` `ERR_USE_AFTER_CLOSE` crash.
336
- const platform = await resolvePlatform({
337
- flag: options.platform,
338
- configPlatform: initConfig?.platform,
380
+ // The init config's `platform` was validated above, so it parses.
381
+ const configPlatforms = initConfig?.platform !== undefined
382
+ ? parsePlatformList(initConfig.platform).platforms
383
+ : undefined;
384
+ const platforms = await resolvePlatforms({
385
+ flag: flagPlatforms,
386
+ configPlatform: configPlatforms,
339
387
  hasConfig: initConfig != null,
340
388
  isTTY: Boolean(process.stdin.isTTY),
341
- promptFn: () => select({
342
- message: "What platform are you building for?",
389
+ promptFn: () => checkbox({
390
+ message: "Which clients are you building? (space to select)",
343
391
  choices: [
344
- { value: "web", name: "web (Vue 3 + TypeScript + Tailwind)" },
392
+ { value: "web", name: "web (Vue 3 + TypeScript + Tailwind)", checked: true },
345
393
  { value: "ios", name: "ios (Swift + SwiftUI)" },
346
394
  ],
347
- default: "web",
395
+ // A run with nothing selected has nothing to scaffold; re-ask rather
396
+ // than proceeding with an empty list.
397
+ validate: (choices) => choices.length > 0 ? true : "Select at least one client.",
348
398
  }),
349
399
  });
350
- // iOS shares the SwiftUI template, Xcode toolchain, and primitive.json
351
- // config path. (macOS was removed in #1009.)
352
- const isApple = platform === "ios";
400
+ // iOS brings the SwiftUI template, the Xcode toolchain and the
401
+ // primitive.json config path — whether it is the only client or one of
402
+ // several. (macOS was removed in #1009.)
403
+ const isApple = platforms.includes("ios");
353
404
  // Apple-family platforms require macOS with Xcode.
354
405
  if (isApple && process.platform !== "darwin") {
355
406
  error("iOS apps require macOS with Xcode installed.");
@@ -363,14 +414,27 @@ async function handleInit(projectDirArg, options) {
363
414
  }
364
415
  }
365
416
  // Step 1: Determine project directory
417
+ //
418
+ // Run from inside an existing Primitive app, one platform goes in a
419
+ // directory named after itself (`ios/`, `web/`) rather than in a new
420
+ // project's. That default is also what makes an `add-client` init config
421
+ // non-interactive without a `dir` key (#2890). A run selecting several
422
+ // platforms names the directory their subdirectories go under, which has no
423
+ // obvious default, so it is asked for as before.
366
424
  let projectDir = projectDirArg;
367
425
  if (!projectDir && initConfig?.dir) {
368
426
  projectDir = initConfig.dir;
369
427
  }
428
+ const clientDirDefault = platforms.length === 1 && findProjectRoot(process.cwd()) ? platforms[0] : null;
429
+ if (!projectDir && clientDirDefault && initConfig) {
430
+ projectDir = clientDirDefault;
431
+ }
370
432
  if (!projectDir) {
371
433
  projectDir = await input({
372
- message: "Project name (a new directory will be created):",
373
- default: "my-primitive-app",
434
+ message: clientDirDefault
435
+ ? "Directory for the new client:"
436
+ : "Project name (a new directory will be created):",
437
+ default: clientDirDefault ?? "my-primitive-app",
374
438
  validate: (value) => {
375
439
  if (!value.trim())
376
440
  return "Project name cannot be empty";
@@ -381,19 +445,53 @@ async function handleInit(projectDirArg, options) {
381
445
  const targetDir = resolve(process.cwd(), projectDir);
382
446
  // Used as the default app name suggestion when prompting
383
447
  const defaultAppName = projectDir === "." ? basename(targetDir) : basename(projectDir);
384
- // Step 2: Check if directory exists and is non-empty
385
- if (existsSync(targetDir) && !isDirectoryEmpty(targetDir)) {
448
+ // ── Which layout is this run producing? (#2890) ────────────────────────
449
+ //
450
+ // A `.primitive/config.json` in an ANCESTOR of the target means this app
451
+ // already exists and this run adds a client to it. Passing the ancestor only
452
+ // when it differs from the target keeps `primitive init .` at a project root
453
+ // on its existing merge behavior.
454
+ const ancestorRoot = findProjectRoot(targetDir);
455
+ const adoptedRoot = ancestorRoot && resolve(ancestorRoot) !== resolve(targetDir) ? ancestorRoot : null;
456
+ const plan = planInit({
457
+ platforms,
458
+ targetDir,
459
+ ancestorProjectRoot: adoptedRoot,
460
+ });
461
+ // The adopted app's identity comes from the project config's selected
462
+ // environment — never from a flag or a prompt.
463
+ let adopted = null;
464
+ if (adoptedRoot) {
465
+ try {
466
+ adopted = resolveAdoptedEnvironment(adoptedRoot, {
467
+ explicitEnvName: getExplicitEnvName(),
468
+ envVarName: process.env.PRIMITIVE_ENV || null,
469
+ });
470
+ }
471
+ catch (err) {
472
+ error(`Could not read the Primitive project at ${adoptedRoot}: ${err.message}`);
473
+ warn("No project files were written.");
474
+ process.exit(1);
475
+ }
476
+ }
477
+ // Step 2: Check if the target — and each client directory under it — exists
478
+ // and is non-empty. Each directory follows the same overwrite contract.
479
+ const directoriesToGuard = [targetDir, ...plan.clients.map((c) => c.dir)].filter((dir, index, all) => all.indexOf(dir) === index);
480
+ for (const dir of directoriesToGuard) {
481
+ if (!existsSync(dir) || isDirectoryEmpty(dir))
482
+ continue;
483
+ const label = dir === targetDir ? projectDir : relative(process.cwd(), dir);
386
484
  if (initConfig) {
387
485
  // Non-interactive: require explicit overwrite opt-in
388
486
  if (!initConfig.overwrite) {
389
- error(`Directory "${projectDir}" already exists and is not empty.`);
487
+ error(`Directory "${label}" already exists and is not empty.`);
390
488
  error('Set overwrite = true in your config file to allow overwriting.');
391
489
  process.exit(1);
392
490
  }
393
- warn(`Directory "${projectDir}" already exists and is not empty. Overwriting (overwrite = true in config).`);
491
+ warn(`Directory "${label}" already exists and is not empty. Overwriting (overwrite = true in config).`);
394
492
  }
395
493
  else {
396
- warn(`Directory "${projectDir}" already exists and is not empty.`);
494
+ warn(`Directory "${label}" already exists and is not empty.`);
397
495
  const overwrite = await confirm({
398
496
  message: "Do you want to overwrite the existing contents?",
399
497
  default: false,
@@ -426,14 +524,21 @@ async function handleInit(projectDirArg, options) {
426
524
  // config's server and then send every later call to the environment's own
427
525
  // `apiUrl`.
428
526
  const currentEnvironment = getCurrentEnvironment();
429
- const currentServer = existingCredentials?.serverUrl ?? currentEnvironment?.config.apiUrl;
527
+ // When adopting, the app already lives somewhere: the environment being
528
+ // adopted fixes the server, and a `--server` that disagrees is a mistake
529
+ // rather than a choice.
530
+ const currentServer = adopted
531
+ ? adopted.config.apiUrl
532
+ : existingCredentials?.serverUrl ?? currentEnvironment?.config.apiUrl;
430
533
  const resolvedServer = await resolveInitServer({
431
534
  flagServer: options.server,
432
535
  configServer: initConfig?.server,
433
536
  hasConfig: initConfig != null,
434
537
  currentServer,
435
538
  defaultServer: DEFAULT_SERVER_URL,
436
- isTTY: Boolean(process.stdin.isTTY),
539
+ // An adopt run has nowhere else to put the client: the environment it is
540
+ // adopting names the server, so there is nothing to ask about.
541
+ isTTY: Boolean(process.stdin.isTTY) && plan.mode !== "add-client",
437
542
  promptFn: async () => {
438
543
  // Only reached when a current server exists (see resolveInitServer).
439
544
  const current = currentServer ?? DEFAULT_SERVER_URL;
@@ -481,7 +586,7 @@ async function handleInit(projectDirArg, options) {
481
586
  //
482
587
  // In legacy mode an interactive run can still log in to the requested server
483
588
  // and have it stored correctly, so it goes through the login flow below.
484
- const inProjectMode = isProjectMode();
589
+ const inProjectMode = isProjectMode() || plan.mode === "add-client";
485
590
  if (serverChanged && (initConfig || inProjectMode)) {
486
591
  const requestedBy = resolvedServer.source === "flag"
487
592
  ? "--server"
@@ -493,7 +598,7 @@ async function handleInit(projectDirArg, options) {
493
598
  // isn't told they're "logged in to" a server they never logged in to.
494
599
  const currentDescription = existingCredentials
495
600
  ? `the CLI is logged in to ${currentServer}`
496
- : `this project's "${currentEnvironment?.name}" environment targets ${currentServer}`;
601
+ : `this project's "${adopted?.name ?? currentEnvironment?.name}" environment targets ${currentServer}`;
497
602
  error(`Not logged in to ${serverUrl}.`);
498
603
  error(`${requestedBy} requests ${serverUrl}, but ${currentDescription}.`);
499
604
  // The remedy differs by mode: `primitive login` resolves its target
@@ -537,46 +642,106 @@ async function handleInit(projectDirArg, options) {
537
642
  warn("No project files were written.");
538
643
  process.exit(1);
539
644
  }
645
+ // Everything this run must be refused for, before a download or a write.
646
+ const guardError = checkInitGuards({
647
+ action: initConfig?.action,
648
+ hasConfig: initConfig != null,
649
+ ancestorProjectRoot: adoptedRoot,
650
+ configAppId: initConfig?.app_id,
651
+ flagAppId: options.useExistingAppId,
652
+ environmentAppId: adopted?.config.appId,
653
+ environmentName: adopted?.name,
654
+ });
655
+ if (guardError) {
656
+ error(guardError);
657
+ warn("No project files were written.");
658
+ process.exit(1);
659
+ }
660
+ if (plan.mode === "add-client") {
661
+ info(`Found an existing Primitive project at ${adoptedRoot} (environment "${adopted.name}").`);
662
+ // A run driven by a config already said what it wants; anything else asks,
663
+ // and a piped stdin gets the key that says it non-interactively rather than
664
+ // `confirmPrompt`'s `--yes` remedy, which init has no flag for.
665
+ if (!initConfig) {
666
+ if (!process.stdin.isTTY) {
667
+ error(`Adding a client to the project at ${adoptedRoot} needs a confirmation, and stdin is not interactive.`);
668
+ error(`Re-run with an init config containing \`action = "add-client"\` to confirm non-interactively.`);
669
+ warn("No project files were written.");
670
+ process.exit(1);
671
+ }
672
+ const proceed = await confirm({
673
+ message: `Add this client to app ${adopted.config.appName ?? adopted.config.appId} in ${adoptedRoot}?`,
674
+ default: true,
675
+ });
676
+ if (!proceed) {
677
+ error("Aborted. No project files were written.");
678
+ process.exit(1);
679
+ }
680
+ }
681
+ }
682
+ // ── The one schema (#2890) ─────────────────────────────────────────────
683
+ //
684
+ // Decided here, before anything is downloaded or written, because the answer
685
+ // can be "stop": an adopted repository whose client keeps its schema
686
+ // somewhere this run does not recognize, or one whose owner declines the
687
+ // move, must end with nothing changed.
688
+ const schemaPlan = plan.canonicalSchemaPath
689
+ ? await resolveSchemaComposition({
690
+ plan,
691
+ promoteConsent: initConfig?.promote_schema === true,
692
+ interactive: initConfig == null && Boolean(process.stdin.isTTY),
693
+ nonInteractive: initConfig != null,
694
+ })
695
+ : null;
540
696
  // Step 4: Check authentication (credentials were loaded for the server
541
697
  // resolution above — reuse them rather than re-reading the file).
542
698
  let credentials = existingCredentials;
543
699
  let appId;
544
700
  let appName;
545
- let preparedTemplate = null;
701
+ const preparedTemplates = new Map();
546
702
  const cleanupPreparedTemplate = () => {
547
- try {
548
- preparedTemplate?.cleanup();
549
- }
550
- catch {
551
- // Non-fatal cleanup best effort
703
+ for (const prepared of preparedTemplates.values()) {
704
+ try {
705
+ prepared.cleanup();
706
+ }
707
+ catch {
708
+ // Non-fatal cleanup best effort
709
+ }
552
710
  }
553
- preparedTemplate = null;
711
+ preparedTemplates.clear();
554
712
  };
555
713
  const prepareTemplateOrExit = async () => {
556
- if (preparedTemplate)
557
- return;
558
- // Download and validate the archive before app creation, but keep it staged
559
- // in a temp directory. That avoids both failure modes: app-creation failure
560
- // leaves no scaffold, and template-download failure leaves no orphaned app.
561
- info(`Preparing your ${platform === "ios" ? "iOS" : "web"} project template...`);
562
- // The branch is the channel (issue #1934): scaffolding against the alpha
563
- // server pulls the alpha template branch, whose Primitive deps pin the
564
- // alpha prerelease libraries. Falls back to main (with a warning) if the
565
- // channel branch isn't available.
566
- const templateBranch = resolveTemplateBranch(serverUrl);
567
- const templateUrlFor = isApple ? iosTemplateUrl : vueTemplateUrl;
568
- try {
569
- preparedTemplate = await prepareTemplate({
570
- templateUrl: templateUrlFor(templateBranch),
571
- fallbackTemplateUrl: templateBranch !== "main" ? templateUrlFor("main") : undefined,
572
- onFallback: (reason) => warn(`Template branch "${templateBranch}" unavailable (${reason}) — falling back to main.`),
573
- onProgress: info,
574
- });
575
- }
576
- catch (err) {
577
- error(`Failed to download template: ${err.message}`);
578
- error("Check your internet connection and try again.");
579
- process.exit(1);
714
+ // EVERY selected template downloads and validates before the app is
715
+ // created, and none of them touches the target directory yet. That keeps
716
+ // both failure modes covered with several clients in play: app-creation
717
+ // failure leaves no scaffold, and a download failure — of either template —
718
+ // leaves no orphaned app and nothing half-written (#1218, #2890).
719
+ for (const client of plan.clients) {
720
+ if (preparedTemplates.has(client.platform))
721
+ continue;
722
+ info(`Preparing your ${platformLabel(client.platform)} project template...`);
723
+ // The branch is the channel (issue #1934): scaffolding against the alpha
724
+ // server pulls the alpha template branch, whose Primitive deps pin the
725
+ // alpha prerelease libraries. Falls back to main (with a warning) if the
726
+ // channel branch isn't available.
727
+ const templateBranch = resolveTemplateBranch(serverUrl);
728
+ const templateUrlFor = client.platform === "ios" ? iosTemplateUrl : vueTemplateUrl;
729
+ try {
730
+ preparedTemplates.set(client.platform, await prepareTemplate({
731
+ templateUrl: templateUrlFor(templateBranch),
732
+ fallbackTemplateUrl: templateBranch !== "main" ? templateUrlFor("main") : undefined,
733
+ onFallback: (reason) => warn(`Template branch "${templateBranch}" unavailable (${reason}) — falling back to main.`),
734
+ onProgress: info,
735
+ }));
736
+ }
737
+ catch (err) {
738
+ error(`Failed to download template: ${err.message}`);
739
+ error("Check your internet connection and try again.");
740
+ // A template that already downloaded must not survive as a temp
741
+ // directory when the run stops here.
742
+ cleanupPreparedTemplate();
743
+ process.exit(1);
744
+ }
580
745
  }
581
746
  };
582
747
  // An existing app can be selected two ways: the `--use-existing-app-id` flag,
@@ -585,7 +750,49 @@ async function handleInit(projectDirArg, options) {
585
750
  // the create branch and prompt for an app name — hanging non-interactive
586
751
  // runs. (#1217)
587
752
  const useExistingAppId = resolveUseExistingAppId(options.useExistingAppId, initConfig);
588
- if (useExistingAppId) {
753
+ if (plan.mode === "add-client") {
754
+ // ── Adopt: the app is the one this project already targets ──────────
755
+ //
756
+ // `appName` is OPTIONAL in a valid environment, so it is resolved rather
757
+ // than assumed: the environment's name, else the app's name from the API
758
+ // when we are authenticated, else the client directory's own name. No
759
+ // downstream writer ever receives an undefined display name, and no
760
+ // scaffold ships an unresolved placeholder.
761
+ appId = adopted.config.appId;
762
+ appName = adopted.config.appName ?? "";
763
+ // Credentials are resolved from the working directory, while the app comes
764
+ // from the project the TARGET sits in — a target inside a different project
765
+ // (or a run from outside any) can leave the CLI holding another server's
766
+ // token, and every API call here would go there with this app's id. The
767
+ // run continues unauthenticated instead: nothing it writes needs the API.
768
+ const credentialServer = credentials ? normalizeServerUrl(credentials.serverUrl) : null;
769
+ if (credentialServer && credentialServer !== normalizeServerUrl(adopted.config.apiUrl)) {
770
+ warn(`The CLI is logged in to ${credentials.serverUrl}, but the "${adopted.name}" environment in ${adoptedRoot} targets ${adopted.config.apiUrl}.`);
771
+ warn(`Continuing without contacting either server. Run init from inside that project, or log in to ${adopted.config.apiUrl}, to have the app verified.`);
772
+ credentials = null;
773
+ }
774
+ else {
775
+ credentials = await ensureAuthenticated(credentials, serverUrl, serverChanged, {
776
+ reason: "verify the app",
777
+ allowSkip: true,
778
+ nonInteractive: !!initConfig,
779
+ });
780
+ }
781
+ if (!appName && credentials) {
782
+ try {
783
+ const client = new ApiClient();
784
+ appName = (await client.getApp(appId)).name;
785
+ }
786
+ catch (err) {
787
+ warn(`Could not look up the app's name: ${err.message}`);
788
+ }
789
+ }
790
+ if (!appName)
791
+ appName = basename(plan.clients[0].dir);
792
+ info(`Adding a ${plan.clients.map((c) => platformLabel(c.platform)).join(" and ")} client to ${appName} (${appId}).`);
793
+ await prepareTemplateOrExit();
794
+ }
795
+ else if (useExistingAppId) {
589
796
  // Using existing app ID - verify it exists
590
797
  appId = useExistingAppId;
591
798
  appName = "(existing app)"; // Default if we can't verify
@@ -786,13 +993,16 @@ async function handleInit(projectDirArg, options) {
786
993
  }
787
994
  }
788
995
  }
789
- // Step 3 (deferred): install the already-prepared template + init git, now
790
- // that the app exists. The network download and archive validation already
791
- // happened before createApp, without touching targetDir.
792
- info(`Setting up your ${platform === "ios" ? "iOS" : "web"} project...`);
996
+ // ── Install every selected template ─────────────────────────────────
997
+ //
998
+ // The network download and archive validation already happened above,
999
+ // without touching any target directory.
793
1000
  try {
794
- preparedTemplate.install(targetDir);
795
- success("Template extracted successfully.");
1001
+ for (const client of plan.clients) {
1002
+ info(`Setting up your ${platformLabel(client.platform)} project...`);
1003
+ preparedTemplates.get(client.platform).install(client.dir);
1004
+ success(`${platformLabel(client.platform)} template extracted into ${client.dir}.`);
1005
+ }
796
1006
  }
797
1007
  catch (err) {
798
1008
  error(`Failed to write template files: ${err.message}`);
@@ -802,92 +1012,196 @@ async function handleInit(projectDirArg, options) {
802
1012
  finally {
803
1013
  cleanupPreparedTemplate();
804
1014
  }
805
- // Initialize git repository (commit happens later, after env + install)
806
- try {
807
- await gitInitRepo(targetDir);
808
- }
809
- catch {
810
- // Non-fatal: git may not be installed, or already a git repo
811
- }
812
- // Step 4.5: Update CLI to point at this app
813
- try {
814
- setCurrentApp(appId, appName);
815
- info(`CLI now using app: ${appName} (${appId})`);
1015
+ // Initialize git repository (commit happens later, after env + install).
1016
+ // An adopted repository already has one, and a nested repository inside it is
1017
+ // exactly what this change stops producing.
1018
+ if (plan.initsGit) {
1019
+ try {
1020
+ await gitInitRepo(plan.projectRoot);
1021
+ }
1022
+ catch {
1023
+ // Non-fatal: git may not be installed, or already a git repo
1024
+ }
816
1025
  }
817
- catch (err) {
818
- // Not critical - user may not be logged in (e.g. used --use-existing-app-id without login)
819
- warn(`Could not set current app in CLI config: ${err.message}`);
1026
+ // Step 4.5: Update CLI to point at this app. Adopting does not: the app is
1027
+ // already the project's, and the CLI's global selection is not this run's to
1028
+ // move.
1029
+ if (plan.ownsApp) {
1030
+ try {
1031
+ setCurrentApp(appId, appName);
1032
+ info(`CLI now using app: ${appName} (${appId})`);
1033
+ }
1034
+ catch (err) {
1035
+ // Not critical - user may not be logged in (e.g. used --use-existing-app-id without login)
1036
+ warn(`Could not set current app in CLI config: ${err.message}`);
1037
+ }
820
1038
  }
821
1039
  // Step 4.6: Scaffold .primitive/config.json with a "dev" environment bound
822
1040
  // to this app, and seed its credential slot with the session we just
823
1041
  // authenticated — so `primitive` commands run from inside the project pick
824
1042
  // up project mode without a second login.
825
- try {
826
- await scaffoldProjectConfig({ targetDir, appId, appName, serverUrl });
827
- if (credentials) {
828
- seedCredentialsForEnvInProject(targetDir, "dev", credentials);
1043
+ if (plan.writesProjectConfig) {
1044
+ try {
1045
+ await scaffoldProjectConfig({
1046
+ targetDir: plan.projectRoot,
1047
+ appId,
1048
+ appName,
1049
+ serverUrl,
1050
+ });
1051
+ if (credentials) {
1052
+ seedCredentialsForEnvInProject(plan.projectRoot, "dev", credentials);
1053
+ }
1054
+ success(`Project environment "dev" configured (.primitive/config.json).`);
1055
+ }
1056
+ catch (err) {
1057
+ warn(`Could not scaffold .primitive/config.json: ${err.message}`);
829
1058
  }
830
- success(`Project environment "dev" configured (.primitive/config.json).`);
831
- }
832
- catch (err) {
833
- warn(`Could not scaffold .primitive/config.json: ${err.message}`);
834
1059
  }
835
- // Steps 5-8: Platform-specific configuration and dependency installation
836
- if (isApple) {
837
- // ── Apple (SwiftUI) path ──────────────────────────────────────────
838
- // Step 5: Update primitive.json
839
- info("Configuring primitive.json...");
1060
+ else {
1061
+ // Adopting: the config is the project's own. Only make sure the local state
1062
+ // a new client's environment produces stays out of the repository.
840
1063
  try {
841
- const updated = updatePrimitiveJson(targetDir, appId, serverUrl, appName);
842
- if (!updated) {
843
- warn("No primitive.json file found in template.");
844
- warn(`Please manually set appId to "${appId}" in your primitive.json.`);
845
- }
846
- else {
847
- success("primitive.json configured.");
848
- }
1064
+ ensureGitignoreLocalState(plan.projectRoot);
849
1065
  }
850
1066
  catch (err) {
851
- warn(`Could not update primitive.json: ${err.message}`);
852
- warn(`Please manually set appId to "${appId}" in your primitive.json.`);
1067
+ warn(`Could not update .gitignore: ${err.message}`);
853
1068
  }
854
- // Step 6-7: Skip port selection and pnpm (not applicable to Apple)
855
- // Step 8: Resolve Swift packages
856
- if (!skipInstall) {
857
- info("Resolving Swift packages (this may take a few minutes)...");
1069
+ }
1070
+ // ── The one model schema (#2890) ────────────────────────────────────
1071
+ if (plan.canonicalSchemaPath && schemaPlan) {
1072
+ composeSchema({ plan, schemaPlan });
1073
+ }
1074
+ // ── The app-wide AGENTS.md ──────────────────────────────────────────
1075
+ if (plan.writesRootAgents) {
1076
+ const agentsPath = join(plan.projectRoot, "AGENTS.md");
1077
+ if (!existsSync(agentsPath)) {
858
1078
  try {
859
- await runSwiftPackageResolve(targetDir);
860
- success("Swift packages resolved.");
1079
+ // The clients this run wrote, plus any the repository already had —
1080
+ // adding iOS to a web app describes a repository with two clients.
1081
+ const written = plan.clients.map((client) => ({
1082
+ platform: client.platform,
1083
+ dir: relative(plan.projectRoot, client.dir) || ".",
1084
+ }));
1085
+ const clients = [
1086
+ ...written,
1087
+ ...discoverExistingClients(plan.projectRoot).filter((existing) => !written.some((client) => client.dir === existing.dir)),
1088
+ ];
1089
+ writeFileSync(agentsPath, rootAgentsMarkdown({ appName, clients }));
1090
+ success("Wrote AGENTS.md describing the app layout.");
861
1091
  }
862
1092
  catch (err) {
863
- warn(`Swift package resolution failed: ${err.message}`);
864
- warn("You can try running 'swift package resolve' manually.");
1093
+ warn(`Could not write AGENTS.md: ${err.message}`);
865
1094
  }
866
1095
  }
867
- else {
868
- info(`Skipping dependency installation (${skipInstallReason}).`);
869
- }
870
1096
  }
871
- else {
1097
+ // ── Per-client configuration and dependency installation ────────────
1098
+ //
1099
+ // The production runtime target is derived once from the project's own
1100
+ // environments, so a client built for production talks to a backend the app
1101
+ // id actually exists on (#2890).
1102
+ const production = plan.mode === "standalone"
1103
+ ? undefined
1104
+ : resolveProductionRuntime({
1105
+ environments: (adopted?.projectConfig.environments ??
1106
+ readProjectEnvironments(plan.projectRoot)),
1107
+ selectedServerUrl: serverUrl,
1108
+ selectedAppId: appId,
1109
+ selectedAppName: appName,
1110
+ productionServerUrl: PRODUCTION_SERVER_URL,
1111
+ });
1112
+ for (const client of plan.clients) {
1113
+ if (client.platform === "ios") {
1114
+ // ── Apple (SwiftUI) path ────────────────────────────────────────
1115
+ // The Xcode build phases name the project config as a declared input,
1116
+ // and inside Xcode's script sandbox a phase may read only what it
1117
+ // declared. When the config lives above this client, both the generated
1118
+ // spec and the checked-in .xcodeproj have to say so — otherwise a fresh
1119
+ // clone (primitive.json is gitignored) cannot build in Xcode at all, and
1120
+ // a working copy that has one keeps whichever environment wrote it.
1121
+ if (resolve(client.dir) !== resolve(plan.projectRoot)) {
1122
+ const declared = declareSharedProjectConfig(client.dir, plan.projectRoot);
1123
+ if (declared.length > 0) {
1124
+ success(`Xcode build phases read ${relative(client.dir, join(plan.projectRoot, ".primitive", "config.json"))}.`);
1125
+ }
1126
+ }
1127
+ // Step 5: Generate primitive.json from the environment.
1128
+ //
1129
+ // In a current template the file is a build product, written by
1130
+ // `scripts/resolve-primitive-config.sh` from `.primitive/config.json`
1131
+ // (#2873) — it walks up to the project root, so a client inside a
1132
+ // composed repository resolves the same one every other command does. A
1133
+ // template that predates the change has no such script and a tracked
1134
+ // primitive.json instead; fill that in the old way.
1135
+ info("Configuring primitive.json...");
1136
+ try {
1137
+ const result = configurePrimitiveJson(client.dir, appId, serverUrl, appName, {
1138
+ // Adopting resolved an environment of the project's own — the one
1139
+ // whose app this client is being added to. The script has to be told
1140
+ // which, or it re-resolves from this machine and can configure the
1141
+ // client for a different app on a different backend.
1142
+ environmentName: adopted?.name,
1143
+ });
1144
+ if (result.outcome === "generated") {
1145
+ success("primitive.json generated from .primitive/config.json.");
1146
+ }
1147
+ else if (result.outcome === "filled") {
1148
+ success("primitive.json configured.");
1149
+ }
1150
+ else if (result.outcome === "failed") {
1151
+ warn(`Could not generate primitive.json: ${result.detail}`);
1152
+ warn("Run 'bash scripts/resolve-primitive-config.sh' in the project to retry.");
1153
+ }
1154
+ else {
1155
+ // Neither the resolve script nor a template primitive.json — an
1156
+ // unexpectedly old or trimmed template. The remedy is still never
1157
+ // "type the app ID somewhere else": it is already in
1158
+ // .primitive/config.json, which is where every reader looks.
1159
+ warn("This template has neither scripts/resolve-primitive-config.sh nor a primitive.json.");
1160
+ warn(`The project config holds the app ID; update the template to generate primitive.json from it.`);
1161
+ }
1162
+ }
1163
+ catch (err) {
1164
+ warn(`Could not configure primitive.json: ${err.message}`);
1165
+ warn("Run 'bash scripts/resolve-primitive-config.sh' in the project to retry.");
1166
+ }
1167
+ // Step 6-7: Skip port selection and pnpm (not applicable to Apple)
1168
+ // Step 8: Resolve Swift packages
1169
+ if (!skipInstall) {
1170
+ info("Resolving Swift packages (this may take a few minutes)...");
1171
+ try {
1172
+ await runSwiftPackageResolve(client.dir);
1173
+ success("Swift packages resolved.");
1174
+ }
1175
+ catch (err) {
1176
+ warn(`Swift package resolution failed: ${err.message}`);
1177
+ warn("You can try running 'swift package resolve' manually.");
1178
+ }
1179
+ }
1180
+ else {
1181
+ info(`Skipping dependency installation (${skipInstallReason}).`);
1182
+ }
1183
+ continue;
1184
+ }
872
1185
  // ── Web path ──────────────────────────────────────────────────────
873
- // Step 5: Update .env file
1186
+ // Step 5: Fill in an OLD template's .env identity keys.
1187
+ //
1188
+ // A current template does not author them at all — `primitiveEnv()` reads
1189
+ // `.primitive/config.json`, which it finds by walking up — so "no keys to
1190
+ // replace" is the expected outcome, not a problem (#2873). The fill-in
1191
+ // survives only so a new CLI still produces a working scaffold from a
1192
+ // template that predates the change.
874
1193
  info("Configuring environment...");
875
1194
  try {
876
- const envResult = updateEnvFile(targetDir, appId, serverUrl, appName);
1195
+ const envResult = updateEnvFile(client.dir, appId, serverUrl, appName, production);
877
1196
  if (!envResult.envUpdated) {
878
- warn("No .env or .env.example file found in template.");
879
- warn(`Please manually set VITE_APP_ID=${appId} in your .env file.`);
880
- }
881
- else if (!envResult.envProductionUpdated) {
882
- info(".env configured. No .env.production file found (this is optional).");
1197
+ info("No .env file in this template — identity comes from .primitive/config.json.");
883
1198
  }
884
1199
  else {
885
- success("Environment configured (.env and .env.production).");
1200
+ success("Environment configured.");
886
1201
  }
887
1202
  }
888
1203
  catch (err) {
889
1204
  warn(`Could not update .env file: ${err.message}`);
890
- warn(`Please manually set VITE_APP_ID=${appId} in your .env file.`);
891
1205
  }
892
1206
  // Step 6: Determine the dev port. Default to 5173; warn about taken ports
893
1207
  // when prompting. The init config's `dev_port` decides it non-interactively
@@ -932,28 +1246,32 @@ async function handleInit(projectDirArg, options) {
932
1246
  // Pin the port in vite.config.ts and update .env. A vite config whose
933
1247
  // shape we can't patch must be said out loud — otherwise the dev
934
1248
  // server keeps 5173 while .env and the redirect URIs point elsewhere.
935
- const viteUpdated = updateViteConfig(targetDir, chosenPort);
1249
+ const viteUpdated = updateViteConfig(client.dir, chosenPort);
936
1250
  if (!viteUpdated) {
937
1251
  warn(`Could not pin port ${chosenPort} in vite.config.ts — set \`server.port\` there manually.`);
938
1252
  }
939
- updateEnvPort(targetDir, chosenPort);
1253
+ updateEnvPort(client.dir, chosenPort);
940
1254
  // Update app settings: CORS, redirect URIs, baseUrl
941
1255
  if (!credentials) {
942
1256
  info(`Local files updated. Skipping server CORS/redirect config — not authenticated.`);
943
1257
  }
944
1258
  else {
945
1259
  try {
946
- const client = new ApiClient();
1260
+ const apiClient = new ApiClient();
947
1261
  let existingOrigins = [];
948
- let existingRedirectUris = [];
1262
+ let existingEmailRedirectUris = [];
1263
+ let existingGoogleClients = null;
949
1264
  try {
950
- const currentSettings = await client.getAppSettings(appId);
1265
+ const currentSettings = await apiClient.getAppSettings(appId);
951
1266
  if (currentSettings.corsMode === "custom" &&
952
1267
  Array.isArray(currentSettings.corsAllowedOrigins)) {
953
1268
  existingOrigins = currentSettings.corsAllowedOrigins;
954
1269
  }
955
- if (Array.isArray(currentSettings.redirectUris)) {
956
- existingRedirectUris = currentSettings.redirectUris;
1270
+ if (Array.isArray(currentSettings.emailRedirectUris)) {
1271
+ existingEmailRedirectUris = currentSettings.emailRedirectUris;
1272
+ }
1273
+ if (currentSettings.googleClients) {
1274
+ existingGoogleClients = currentSettings.googleClients;
957
1275
  }
958
1276
  }
959
1277
  catch {
@@ -961,18 +1279,45 @@ async function handleInit(projectDirArg, options) {
961
1279
  }
962
1280
  // Always include 5173 since it's the universal localhost default
963
1281
  const corsOrigins = Array.from(new Set([...existingOrigins, defaultOrigin, devOrigin]));
964
- const redirectUris = Array.from(new Set([
965
- ...existingRedirectUris,
1282
+ // MERGED into the creation-time seed, never replacing it (#2891):
1283
+ // `emailRedirectUris` matching is fail-closed, so blindly writing
1284
+ // only this port's callback would break every other environment the
1285
+ // app already serves.
1286
+ const emailRedirectUris = Array.from(new Set([
1287
+ ...existingEmailRedirectUris,
966
1288
  `${defaultOrigin}/oauth/callback`,
967
1289
  `${devOrigin}/oauth/callback`,
968
1290
  ]));
969
- await client.updateAppSettings(appId, {
1291
+ const settingsUpdate = {
970
1292
  corsMode: "custom",
971
1293
  corsAllowedOrigins: corsOrigins,
972
1294
  corsAllowCredentials: true,
973
- redirectUris,
1295
+ emailRedirectUris,
974
1296
  baseUrl: devOrigin,
975
- });
1297
+ };
1298
+ // A Google web client redirects to this dev origin too, so its own
1299
+ // `redirectUris` needs the callback. A PUT replaces the WHOLE map,
1300
+ // so the complete map is sent back — an entry for a platform this
1301
+ // scaffold does not touch (ios, desktop, …) would otherwise be
1302
+ // deleted as a side effect of choosing a dev port.
1303
+ const webEntry = existingGoogleClients?.clients?.web;
1304
+ if (webEntry) {
1305
+ settingsUpdate.googleClients = {
1306
+ ...existingGoogleClients,
1307
+ clients: {
1308
+ ...existingGoogleClients.clients,
1309
+ web: {
1310
+ ...webEntry,
1311
+ redirectUris: Array.from(new Set([
1312
+ ...(webEntry.redirectUris ?? []),
1313
+ `${defaultOrigin}/oauth/callback`,
1314
+ `${devOrigin}/oauth/callback`,
1315
+ ])),
1316
+ },
1317
+ },
1318
+ };
1319
+ }
1320
+ await apiClient.updateAppSettings(appId, settingsUpdate);
976
1321
  success(`Dev port set to ${chosenPort}: ${viteUpdated ? "vite.config.ts, " : ""}.env, CORS, redirect URIs, and base URL updated.`);
977
1322
  }
978
1323
  catch (err) {
@@ -987,15 +1332,26 @@ async function handleInit(projectDirArg, options) {
987
1332
  warn(`Could not configure the dev server port: ${err.message}`);
988
1333
  warn("The project keeps the default port 5173.");
989
1334
  }
990
- // Step 7: Check if pnpm is installed globally, offer to install if not
1335
+ // Step 7: Check if pnpm is installed globally, offer to install if not.
1336
+ //
1337
+ // An offer, so only where there is someone to ask: a config-driven run
1338
+ // answers no questions, and a piped stdin has no answer to give. Either way
1339
+ // the install below still runs pnpm through npx, so the client gets its
1340
+ // dependencies — this is about the machine, not about this project.
991
1341
  const pnpmInstalled = await isPnpmInstalled();
992
1342
  if (!pnpmInstalled) {
993
1343
  console.log();
994
1344
  info("pnpm is not installed globally on your system.");
995
- const installPnpm = await confirm({
996
- message: "Install pnpm globally? (recommended for future use)",
997
- default: true,
998
- });
1345
+ const canAsk = initConfig == null && Boolean(process.stdin.isTTY);
1346
+ const installPnpm = canAsk
1347
+ ? await confirm({
1348
+ message: "Install pnpm globally? (recommended for future use)",
1349
+ default: true,
1350
+ })
1351
+ : false;
1352
+ if (!canAsk) {
1353
+ info("Using npx to run pnpm. Install it globally later with: npm install -g pnpm");
1354
+ }
999
1355
  if (installPnpm) {
1000
1356
  info("Installing pnpm...");
1001
1357
  try {
@@ -1012,7 +1368,7 @@ async function handleInit(projectDirArg, options) {
1012
1368
  if (!skipInstall) {
1013
1369
  info("Installing dependencies...");
1014
1370
  try {
1015
- await runPnpmInstall(targetDir);
1371
+ await runPnpmInstall(client.dir);
1016
1372
  success("Dependencies installed.");
1017
1373
  }
1018
1374
  catch (err) {
@@ -1085,39 +1441,275 @@ async function handleInit(projectDirArg, options) {
1085
1441
  catch {
1086
1442
  // Non-fatal: skill installation should never block init
1087
1443
  }
1088
- // Step 11: Create initial git commit (captures env config + installed dependencies)
1089
- try {
1090
- await gitInitialCommit(targetDir);
1091
- }
1092
- catch {
1093
- // Non-fatal: git may not be installed, or already committed
1444
+ // Step 11: Create initial git commit (captures env config + installed
1445
+ // dependencies). Not when adopting: the surrounding repository owns its
1446
+ // history, and the new files are the user's to review and commit.
1447
+ if (plan.initsGit) {
1448
+ try {
1449
+ await gitInitialCommit(plan.projectRoot);
1450
+ }
1451
+ catch {
1452
+ // Non-fatal: git may not be installed, or already committed
1453
+ }
1094
1454
  }
1095
1455
  // Step 12: Show success message
1096
1456
  divider();
1097
- success("Project initialized successfully!");
1457
+ success(plan.mode === "add-client"
1458
+ ? "Client added successfully!"
1459
+ : "Project initialized successfully!");
1098
1460
  divider();
1099
- keyValue("Directory", targetDir);
1461
+ keyValue("Project", plan.projectRoot);
1100
1462
  keyValue("App Name", appName);
1101
1463
  keyValue("App ID", appId);
1102
- keyValue("Platform", platform === "ios" ? "iOS (SwiftUI)" : "Web");
1464
+ for (const client of plan.clients) {
1465
+ keyValue(platformLabel(client.platform), client.dir);
1466
+ }
1467
+ if (plan.canonicalSchemaPath) {
1468
+ keyValue("Model schema", plan.canonicalSchemaPath);
1469
+ }
1103
1470
  divider();
1471
+ if (plan.mode === "add-client") {
1472
+ info(`No .primitive/ and no .git/ were created inside the new client — this app has one of each, at ${plan.projectRoot}.`);
1473
+ info(`The new files are uncommitted: review them with \`git status\` in ${plan.projectRoot} and commit when you are happy.`);
1474
+ divider();
1475
+ }
1104
1476
  info("Next steps:");
1105
- if (projectDir !== ".") {
1106
- console.log(` cd ${projectDir}`);
1477
+ for (const client of plan.clients) {
1478
+ const relativeDir = relative(process.cwd(), client.dir);
1479
+ // A target outside the working directory reads better as its own path than
1480
+ // as a chain of `../`.
1481
+ const where = relativeDir.startsWith("..") ? client.dir : relativeDir || ".";
1482
+ if (where !== ".")
1483
+ console.log(` cd ${where}`);
1484
+ if (client.platform === "ios") {
1485
+ if (skipInstall)
1486
+ console.log(" swift package resolve");
1487
+ // The Xcode project lists its sources explicitly, and the shared schema
1488
+ // brings models the checked-in project does not compile yet — so the
1489
+ // first build regenerates. `run-ios.sh` runs codegen and xcodegen in that
1490
+ // order; a build straight from Xcode before that stops and says so.
1491
+ if (resolve(client.dir) !== resolve(plan.projectRoot)) {
1492
+ console.log(" ./run-ios.sh # codegen + xcodegen, then the Simulator");
1493
+ console.log(" # then: open *.xcodeproj");
1494
+ }
1495
+ else {
1496
+ console.log(" open *.xcodeproj");
1497
+ console.log(" # or: ./run-ios.sh (iOS Simulator)");
1498
+ }
1499
+ }
1500
+ else {
1501
+ if (skipInstall)
1502
+ console.log(" pnpm install");
1503
+ console.log(" pnpm dev");
1504
+ }
1107
1505
  }
1108
- if (isApple) {
1109
- if (skipInstall) {
1110
- console.log(" swift package resolve");
1506
+ console.log();
1507
+ }
1508
+ /** Where a project root's environments live, for the production derivation. */
1509
+ function readProjectEnvironments(projectRoot) {
1510
+ const configPath = join(projectRoot, PROJECT_CONFIG_DIR, PROJECT_CONFIG_FILENAME);
1511
+ if (!existsSync(configPath))
1512
+ return undefined;
1513
+ try {
1514
+ return loadProjectConfigFromPath(configPath).environments;
1515
+ }
1516
+ catch {
1517
+ return undefined;
1518
+ }
1519
+ }
1520
+ /**
1521
+ * The clients a repository already has, for the app-wide `AGENTS.md`.
1522
+ *
1523
+ * A run that adds a client to an existing app knows only about the one it is
1524
+ * writing, and a layout description that names one client of two describes the
1525
+ * repository wrongly to the next agent that reads it. The signals are the ones
1526
+ * the templates ship — a Swift package or Xcode project, a Vite project — so a
1527
+ * `functions/` directory with a `package.json` is not mistaken for a client.
1528
+ */
1529
+ function discoverExistingClients(projectRoot) {
1530
+ let entries;
1531
+ try {
1532
+ entries = readdirSync(projectRoot, { withFileTypes: true });
1533
+ }
1534
+ catch {
1535
+ return [];
1536
+ }
1537
+ const found = [];
1538
+ for (const entry of entries) {
1539
+ if (!entry.isDirectory() || entry.name.startsWith("."))
1540
+ continue;
1541
+ const dir = join(projectRoot, entry.name);
1542
+ const hasXcodeProject = (() => {
1543
+ try {
1544
+ return readdirSync(dir).some((name) => name.endsWith(".xcodeproj"));
1545
+ }
1546
+ catch {
1547
+ return false;
1548
+ }
1549
+ })();
1550
+ if (existsSync(join(dir, "Package.swift")) || hasXcodeProject) {
1551
+ found.push({ platform: "ios", dir: entry.name });
1552
+ }
1553
+ else if (existsSync(join(dir, "package.json")) &&
1554
+ existsSync(join(dir, "vite.config.ts"))) {
1555
+ found.push({ platform: "web", dir: entry.name });
1556
+ }
1557
+ }
1558
+ return found;
1559
+ }
1560
+ /**
1561
+ * Decides how this run gets to ONE schema — before anything is downloaded or
1562
+ * written, because the answer can be "stop".
1563
+ */
1564
+ async function resolveSchemaComposition(input) {
1565
+ const schemaPath = input.plan.canonicalSchemaPath;
1566
+ if (existsSync(schemaPath))
1567
+ return { alreadyPresent: true };
1568
+ // A fresh composition seeds the shared schema from the clients it is about
1569
+ // to scaffold; there is nothing to move and nobody to ask.
1570
+ if (input.plan.mode !== "add-client")
1571
+ return { alreadyPresent: false };
1572
+ const root = input.plan.projectRoot;
1573
+ const existing = ["web", "ios"]
1574
+ .map((platform) => ({ platform, schemaPath: findClientSchema(root, platform) }))
1575
+ .find((candidate) => candidate.schemaPath !== null);
1576
+ if (existing) {
1577
+ // The flat single-client layout `primitive init` still produces: the client
1578
+ // IS the repository root, and its schema has to become the app's.
1579
+ const from = relative(root, existing.schemaPath);
1580
+ // Moving the schema is only half of it — what reads the schema has to be
1581
+ // able to name the new location. A client that cannot be rewired would keep
1582
+ // its own copy beside the app's, which is the drift the one schema exists
1583
+ // to prevent, so the run stops here: before the download, before the move,
1584
+ // with the repository exactly as it was.
1585
+ const blocked = checkClientWiring(root, existing.platform, schemaPath);
1586
+ if (blocked) {
1587
+ error(`The ${platformLabel(existing.platform)} client at ${root} cannot be pointed at ${relative(root, schemaPath)}: ${blocked}.`);
1588
+ error(`Every client of an app reads one schema, so ${from} stays where it is. Restore that file to its template shape, or move the schema and rewire the client by hand, then re-run this command.`);
1589
+ warn("No project files were written.");
1590
+ process.exit(1);
1591
+ }
1592
+ info(`This repository's model schema is at ${from}. Adding a second client means moving it to models/models.toml so both clients read one copy.`);
1593
+ if (!input.promoteConsent) {
1594
+ if (!input.interactive) {
1595
+ error(`Adding a client needs the schema at models/models.toml, and moving ${from} there needs your consent.`);
1596
+ error(input.nonInteractive
1597
+ ? 'Set `promote_schema = true` alongside `action = "add-client"` in the init config to allow the move.'
1598
+ : "Re-run from an interactive terminal, or with an init config setting `promote_schema = true`, to allow the move.");
1599
+ warn("No project files were written.");
1600
+ process.exit(1);
1601
+ }
1602
+ const proceed = await confirm({
1603
+ message: `Move ${from} to models/models.toml and point both clients at it?`,
1604
+ default: true,
1605
+ });
1606
+ if (!proceed) {
1607
+ error("Aborted. No project files were written.");
1608
+ process.exit(1);
1609
+ }
1610
+ }
1611
+ return {
1612
+ alreadyPresent: false,
1613
+ promoteFrom: { dir: root, platform: existing.platform, schemaPath: existing.schemaPath },
1614
+ };
1615
+ }
1616
+ // A root that looks like a client but keeps its schema somewhere neither
1617
+ // template does: say exactly what to do, and write nothing.
1618
+ const looksLikeClient = existsSync(join(root, "package.json")) || existsSync(join(root, "Package.swift"));
1619
+ if (looksLikeClient) {
1620
+ error(`The project at ${root} has a client, but no model schema where either template keeps one (src/models/models.toml, or Sources/<App>/Models/models.toml).`);
1621
+ error(`Move that client's schema to ${root}/models/models.toml and point its codegen at it, then re-run this command.`);
1622
+ warn("No project files were written.");
1623
+ process.exit(1);
1624
+ }
1625
+ return { alreadyPresent: false };
1626
+ }
1627
+ /**
1628
+ * Writes the app's one schema and points every client at it.
1629
+ *
1630
+ * The promoted file and the new wiring are written BEFORE the old client-local
1631
+ * copy goes away, so an interrupted promotion leaves a client with a schema
1632
+ * rather than one with neither.
1633
+ */
1634
+ function composeSchema(input) {
1635
+ const { plan, schemaPlan } = input;
1636
+ const schemaPath = plan.canonicalSchemaPath;
1637
+ const reportAddedModels = (added) => {
1638
+ if (added.length === 0)
1639
+ return;
1640
+ info(`Added ${added.map((model) => `\`${model}\``).join(", ")} to ${relative(plan.projectRoot, schemaPath)} — the models the added client's own code reads. Models the app already declares are untouched.`);
1641
+ };
1642
+ const clientsToWire = [
1643
+ ...(schemaPlan.promoteFrom
1644
+ ? [{ platform: schemaPlan.promoteFrom.platform, dir: schemaPlan.promoteFrom.dir }]
1645
+ : []),
1646
+ ...plan.clients,
1647
+ ];
1648
+ // Asked of every client before the shared schema exists. A client that cannot
1649
+ // be pointed at it keeps its own copy — so writing the shared one anyway, and
1650
+ // wiring whichever clients did work, would leave the app with two schemas and
1651
+ // the run reporting success. One schema, or none: the existing client's
1652
+ // shape was already checked before the download, so this catches an installed
1653
+ // template whose wiring this CLI no longer recognizes.
1654
+ const unreachable = clientsToWire
1655
+ .map((client) => ({
1656
+ client,
1657
+ reason: checkClientWiring(client.dir, client.platform, schemaPath),
1658
+ }))
1659
+ .filter((checked) => checked.reason !== null);
1660
+ if (unreachable.length > 0) {
1661
+ for (const { client, reason } of unreachable) {
1662
+ error(`The ${platformLabel(client.platform)} client at ${client.dir} cannot be pointed at ${schemaPath}: ${reason}.`);
1663
+ }
1664
+ error("Every client of an app reads one model schema, so nothing was moved and no client was rewired.");
1665
+ warn(`The templates this run installed are still in place; remove them and re-run, or point each client at ${schemaPath} by hand.`);
1666
+ process.exit(1);
1667
+ }
1668
+ // Every client this run installs ships a starter schema AND code generated
1669
+ // from it — the web template's store reads `UserPref`, and its generator
1670
+ // refuses a schema with no models at all. So the app's schema has to declare
1671
+ // what each client reads, whether it is being written now or was already
1672
+ // there: a promoted comment-only Swift starter would otherwise hand a new web
1673
+ // client a schema it cannot generate from, and the run would report success.
1674
+ const starters = plan.clients
1675
+ .map((client) => findClientSchema(client.dir, client.platform))
1676
+ .filter((found) => found !== null)
1677
+ .map((found) => readFileSync(found, "utf-8"));
1678
+ if (!schemaPlan.alreadyPresent) {
1679
+ // The base is the promoted client's schema when one is being promoted —
1680
+ // its models are the app's — else the richest starter this run installs.
1681
+ const base = (schemaPlan.promoteFrom
1682
+ ? readFileSync(schemaPlan.promoteFrom.schemaPath, "utf-8")
1683
+ : starters.find((starter) => declaredModelNames(starter).length > 0)) ??
1684
+ starters[0] ??
1685
+ FALLBACK_STARTER_SCHEMA;
1686
+ const composed = composeSchemaContent({ base, starters });
1687
+ if (writeCanonicalSchema(schemaPath, `${CANONICAL_SCHEMA_HEADER}\n${composed.content}`)) {
1688
+ success(`Wrote ${relative(plan.projectRoot, schemaPath)} — the app's one model schema.`);
1689
+ reportAddedModels(schemaPlan.promoteFrom ? composed.added : []);
1111
1690
  }
1112
- console.log(" open *.xcodeproj");
1113
- console.log(" # or: ./run-ios.sh (iOS Simulator)");
1114
1691
  }
1115
1692
  else {
1116
- if (skipInstall) {
1117
- console.log(" pnpm install");
1693
+ const composed = composeSchemaContent({
1694
+ base: readFileSync(schemaPath, "utf-8"),
1695
+ starters,
1696
+ });
1697
+ if (composed.added.length > 0) {
1698
+ writeFileSync(schemaPath, composed.content);
1699
+ reportAddedModels(composed.added);
1118
1700
  }
1119
- console.log(" pnpm dev");
1120
1701
  }
1121
- console.log();
1702
+ for (const client of clientsToWire) {
1703
+ const result = wireClientToSchema(client.dir, client.platform, schemaPath);
1704
+ if (result.error) {
1705
+ // The check above said this client could be wired, so an error here is
1706
+ // the filesystem changing under the run — still not something to finish
1707
+ // on top of, because it is a client reading a schema of its own.
1708
+ error(`Could not point the ${platformLabel(client.platform)} client at the shared schema: ${result.error}.`);
1709
+ error(`Point it at ${schemaPath} before using this repository.`);
1710
+ process.exit(1);
1711
+ }
1712
+ success(`${platformLabel(client.platform)} client reads ${relative(client.dir, schemaPath)}.`);
1713
+ }
1122
1714
  }
1123
1715
  //# sourceMappingURL=init.js.map