t44 0.4.0-rc.21 → 0.4.0-rc.23

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.
package/README.md CHANGED
@@ -3,6 +3,7 @@
3
3
  <td><a href="https://Stream44.Studio"><img src=".o/stream44.studio/assets/Icon-v1.svg" width="42" height="42"></a></td>
4
4
  <td><strong><a href="https://Stream44.Studio">Stream44 Studio</a></strong><br/>Open Development Project</td>
5
5
  <td>Preview release for community feedback.<br/>Get in touch on <a href="https://discord.gg/9eBcQXEJAN">discord</a>.</td>
6
+ <td>Hand Designed<br/><b>AI Coded Alpha</a></td>
6
7
  </tr>
7
8
  </table>
8
9
 
@@ -168,7 +169,6 @@ allows for rapid deployment of changes but more fundamentally allows for the
168
169
  introduction of capability-based computing where code executes in restricted
169
170
  sandboxes only able to leverage capabilities specifically given.
170
171
 
171
-
172
172
  Provenance
173
173
  ===
174
174
 
@@ -1,5 +1,7 @@
1
1
 
2
- import { join } from 'path'
2
+ import { join, dirname } from 'path'
3
+ import { readFile, access } from 'fs/promises'
4
+ import { constants } from 'fs'
3
5
  import chalk from 'chalk'
4
6
 
5
7
  // ── Provider Lifecycle ─────────────────────────────────────────────
@@ -159,6 +161,14 @@ export async function capsule({
159
161
  const stepText = deprovision ? 'Deprovisioning' : 'Deploying'
160
162
  console.log(`\n=> ${stepText} provider project alias '${alias}' for workspace project '${projectName}' ...\n`)
161
163
 
164
+ // ── Build step (deploy only) ──────────────────
165
+ if (!deprovision) {
166
+ const aliasConfig = projectConfig[alias]
167
+ if (aliasConfig.sourceDir) {
168
+ await runBuildIfAvailable(aliasConfig.sourceDir)
169
+ }
170
+ }
171
+
162
172
  await callProvidersForAlias(step, projectConfig[alias], { alias, projectName })
163
173
 
164
174
  console.log(`\n<= ${stepText} of provider project alias '${alias}' for workspace project '${projectName}' done.\n`)
@@ -319,6 +329,67 @@ function formatDuration(status: any): string {
319
329
  }
320
330
 
321
331
 
332
+ // ── Build step: find closest package.json with build script ───────
333
+ async function runBuildIfAvailable(sourceDir: string): Promise<void> {
334
+ // Walk up from sourceDir to find the closest package.json with a build script
335
+ let currentDir = sourceDir
336
+
337
+ // Normalize: if sourceDir ends with a known build output folder, start from parent
338
+ const buildOutputFolders = ['dist', 'build', 'out', '.next', '.output']
339
+ const lastSegment = sourceDir.split('/').pop()
340
+ if (lastSegment && buildOutputFolders.includes(lastSegment)) {
341
+ currentDir = dirname(sourceDir)
342
+ }
343
+
344
+ // Walk up looking for package.json with build script
345
+ const maxDepth = 5
346
+ for (let i = 0; i < maxDepth; i++) {
347
+ const pkgPath = join(currentDir, 'package.json')
348
+ try {
349
+ await access(pkgPath, constants.F_OK)
350
+ const pkgContent = await readFile(pkgPath, 'utf-8')
351
+ const pkg = JSON.parse(pkgContent)
352
+
353
+ if (pkg.scripts?.build) {
354
+ console.log(chalk.cyan(`Building ${pkg.name || currentDir} ...`))
355
+ console.log(chalk.gray(` Directory: ${currentDir}`))
356
+ console.log(chalk.gray(` Script: ${pkg.scripts.build}\n`))
357
+
358
+ const proc = Bun.spawn(['bun', 'run', 'build'], {
359
+ cwd: currentDir,
360
+ stdin: 'inherit',
361
+ stdout: 'inherit',
362
+ stderr: 'inherit'
363
+ })
364
+
365
+ const exitCode = await proc.exited
366
+ if (exitCode !== 0) {
367
+ throw new Error(`Build failed with exit code ${exitCode}`)
368
+ }
369
+
370
+ console.log(chalk.green(`Build complete.\n`))
371
+ return
372
+ }
373
+ } catch (err: any) {
374
+ // If it's our own error (build failed), rethrow
375
+ if (err.message?.includes('Build failed')) {
376
+ throw err
377
+ }
378
+ // Otherwise, package.json doesn't exist or isn't valid, continue walking up
379
+ }
380
+
381
+ const parentDir = dirname(currentDir)
382
+ if (parentDir === currentDir) {
383
+ // Reached filesystem root
384
+ break
385
+ }
386
+ currentDir = parentDir
387
+ }
388
+
389
+ // No build script found - that's okay, just skip
390
+ }
391
+
392
+
322
393
  function orderAliasesByDependencies(deploymentConfig: Record<string, any>): string[] {
323
394
  const aliases = Object.keys(deploymentConfig)
324
395
  const ordered: string[] = []
@@ -104,12 +104,15 @@ export async function capsule({
104
104
  const isDryRun = !rc && !release && !bump && !publish
105
105
  const shouldBumpVersions = rc || release || bump
106
106
 
107
- // ── Provider filter (tag-based) ────────────────────
107
+ // ── Provider filter (tag-based + enabled flag) ───────
108
108
  // When --publish <filter> is given, only providers whose
109
109
  // capsule exposes a matching `tags` property will run.
110
110
  // Tags are queried from the loaded capsule, not from config.
111
+ // Additionally, providers with `enabled: false` are always skipped.
111
112
  const publishFilter = typeof publish === 'string' ? publish : null
112
113
  const isProviderIncluded = async (providerConfig: any): Promise<boolean> => {
114
+ // Check enabled flag first - if explicitly false, skip this provider
115
+ if (providerConfig.enabled === false) return false
113
116
  if (!publishFilter) return true
114
117
  const provider = await getProvider(providerConfig.capsule)
115
118
  const tags: string[] | undefined = provider.tags
@@ -147,10 +150,23 @@ export async function capsule({
147
150
  for (const repoProvider of repoProviders) {
148
151
  const capsuleName = repoProvider.capsule
149
152
  const globalDefault = globalDefaults.get(capsuleName)
150
- merged.push(globalDefault
151
- ? { ...repoProvider, config: deepMerge(globalDefault.config, repoProvider.config) }
152
- : repoProvider
153
- )
153
+ if (globalDefault) {
154
+ // Merge: repo-level enabled overrides global, config is deep-merged
155
+ const mergedProvider = {
156
+ ...globalDefault,
157
+ ...repoProvider,
158
+ config: deepMerge(globalDefault.config, repoProvider.config),
159
+ }
160
+ // Explicit enabled at repo level takes precedence
161
+ if ('enabled' in repoProvider) {
162
+ mergedProvider.enabled = repoProvider.enabled
163
+ } else if ('enabled' in globalDefault) {
164
+ mergedProvider.enabled = globalDefault.enabled
165
+ }
166
+ merged.push(mergedProvider)
167
+ } else {
168
+ merged.push(repoProvider)
169
+ }
154
170
  seen.add(capsuleName)
155
171
  }
156
172
 
@@ -242,6 +258,7 @@ export async function capsule({
242
258
  ctx: any,
243
259
  ) => {
244
260
  const providers = resolveRepoProviders(repoConfig, globalProviders)
261
+ ctx.mergedProviders = providers
245
262
  for (const providerConfig of providers) {
246
263
  if (!await isProviderIncluded(providerConfig)) continue
247
264
 
@@ -53,7 +53,7 @@ export async function capsule({
53
53
  const projectionDir = ctx.publishingApi.getProjectionDir(capsule['#'])
54
54
  const stageDir = join(projectionDir, 'stage', originUri.replace(/[\/]/g, '~'))
55
55
 
56
- // Clone if repository doesn't exist yet
56
+ // ── 1. Clone if repository doesn't exist yet ────────────
57
57
  let isNewEmptyRepo = false
58
58
  const repoExists = await this.ProjectRepository.exists({ rootDir: stageDir })
59
59
  if (!repoExists) {
@@ -62,7 +62,14 @@ export async function capsule({
62
62
  isNewEmptyRepo = result.isNewEmptyRepo
63
63
  }
64
64
 
65
- // Set local git author from RepositorySettings config
65
+ // ── 2. Ensure origin remote exists (heal if missing) ────
66
+ const hasOrigin = await this.ProjectRepository.hasRemote({ rootDir: stageDir, name: 'origin' })
67
+ if (!hasOrigin) {
68
+ console.log(`Re-adding missing 'origin' remote ...`)
69
+ await this.ProjectRepository.addRemote({ rootDir: stageDir, name: 'origin', url: originUri })
70
+ }
71
+
72
+ // ── 3. Set local git author ─────────────────────────────
66
73
  if (authorConfig?.name) {
67
74
  await $`git config user.name ${authorConfig.name}`.cwd(stageDir).quiet()
68
75
  }
@@ -70,7 +77,85 @@ export async function capsule({
70
77
  await $`git config user.email ${authorConfig.email}`.cwd(stageDir).quiet()
71
78
  }
72
79
 
73
- // Sync files using rsync with gitignore support and delete removed files
80
+ // ── 4. Determine target branch ──────────────────────────
81
+ const targetBranch = ctx.options.branch
82
+ const effectiveBranch = targetBranch || 'main'
83
+
84
+ // ── 5. Detect empty repo ────────────────────────────────
85
+ const headCheck = await $`git rev-parse HEAD`.cwd(stageDir).quiet().nothrow()
86
+ const isEmptyRepo = isNewEmptyRepo || headCheck.exitCode !== 0
87
+
88
+ // ── 6. Fetch from remote ────────────────────────────────
89
+ // Always fetch so we know the true state of the remote
90
+ // before making any branch decisions. Skip for empty repos
91
+ // that were just created (nothing to fetch).
92
+ if (!isEmptyRepo) {
93
+ await $`git fetch origin`.cwd(stageDir).quiet().nothrow()
94
+ }
95
+
96
+ // ── 7. Clean working tree and sync branch to remote ─────
97
+ // This is the critical section: we must get the local branch
98
+ // to exactly match the remote before rsyncing source files.
99
+ let branchSwitched = false
100
+ if (isEmptyRepo) {
101
+ await $`git checkout -b ${effectiveBranch}`.cwd(stageDir).quiet().nothrow()
102
+ console.log(`Initialized branch '${effectiveBranch}' on empty repository`)
103
+ branchSwitched = true
104
+ } else {
105
+ // Discard any uncommitted changes from previous runs
106
+ await $`git checkout .`.cwd(stageDir).quiet().nothrow()
107
+ await $`git clean -fd`.cwd(stageDir).quiet().nothrow()
108
+
109
+ const currentBranch = await this.ProjectRepository.getBranch({ rootDir: stageDir })
110
+
111
+ if (currentBranch !== effectiveBranch) {
112
+ console.log(`Switching from branch '${currentBranch}' to '${effectiveBranch}' ...`)
113
+
114
+ // Check if branch exists locally
115
+ const localBranchCheck = await $`git branch --list ${effectiveBranch}`.cwd(stageDir).quiet().nothrow()
116
+ const localBranchExists = localBranchCheck.text().trim().length > 0
117
+
118
+ if (localBranchExists) {
119
+ await $`git checkout ${effectiveBranch}`.cwd(stageDir).quiet()
120
+ } else {
121
+ // Check if branch exists on remote
122
+ const remoteBranchCheck = await $`git ls-remote --heads origin ${effectiveBranch}`.cwd(stageDir).quiet().nothrow()
123
+ const remoteBranchExists = remoteBranchCheck.text().trim().length > 0
124
+
125
+ if (remoteBranchExists) {
126
+ await $`git checkout -b ${effectiveBranch} origin/${effectiveBranch}`.cwd(stageDir).quiet()
127
+ } else {
128
+ await $`git checkout -b ${effectiveBranch}`.cwd(stageDir).quiet()
129
+ console.log(`Created new branch '${effectiveBranch}'`)
130
+ }
131
+ }
132
+ branchSwitched = true
133
+ }
134
+
135
+ // Hard-reset local branch to match remote (if remote branch exists).
136
+ // This ensures the local stage repo always starts from the true
137
+ // remote state, regardless of what happened in previous runs.
138
+ const remoteRef = `origin/${effectiveBranch}`
139
+ const remoteRefCheck = await $`git rev-parse --verify ${remoteRef}`.cwd(stageDir).quiet().nothrow()
140
+ if (remoteRefCheck.exitCode === 0) {
141
+ const localHead = (await $`git rev-parse HEAD`.cwd(stageDir).quiet()).text().trim()
142
+ const remoteHead = remoteRefCheck.text().trim()
143
+ if (localHead !== remoteHead) {
144
+ await $`git reset --hard ${remoteRef}`.cwd(stageDir).quiet()
145
+ console.log(`Synced local '${effectiveBranch}' to remote (${remoteHead.slice(0, 8)})`)
146
+ }
147
+ }
148
+
149
+ if (branchSwitched) {
150
+ console.log(`On branch '${effectiveBranch}'`)
151
+ } else if (targetBranch) {
152
+ console.log(`Already on branch '${targetBranch}'`)
153
+ }
154
+ }
155
+
156
+ // ── 8. Rsync source files into stage repo ───────────────
157
+ // Now that the branch is in sync with remote, overlay
158
+ // the workspace source files on top.
74
159
  const gitignorePath = join(ctx.repoSourceDir, '.gitignore')
75
160
  await this.ProjectRepository.sync({
76
161
  rootDir: stageDir,
@@ -79,7 +164,7 @@ export async function capsule({
79
164
  excludePatterns: ctx.alwaysIgnore || []
80
165
  })
81
166
 
82
- // Check for .env* files in the stage dir — these can leak sensitive information
167
+ // ── 9. Security check: .env* files ──────────────────────
83
168
  const envFilesResult = await $`find . -name '.env*' -not -path './.git/*'`.cwd(stageDir).quiet().nothrow()
84
169
  const envFiles = envFilesResult.text().trim().split('\n').filter(Boolean)
85
170
  if (envFiles.length > 0) {
@@ -94,7 +179,7 @@ export async function capsule({
94
179
  process.exit(1)
95
180
  }
96
181
 
97
- // Generate files from config properties starting with '/'
182
+ // ── 10. Generate files from config ──────────────────────
98
183
  // This happens AFTER rsync so generated files are not overwritten
99
184
  if (config.config) {
100
185
  for (const [key, value] of Object.entries(config.config)) {
@@ -127,52 +212,7 @@ export async function capsule({
127
212
  }
128
213
  }
129
214
 
130
- // Determine target branch (defaults to 'main' when --branch is not specified)
131
- const targetBranch = ctx.options.branch
132
- const effectiveBranch = targetBranch || 'main'
133
-
134
- // Detect if the stage repo is on a different branch than the target
135
- // Also detect empty repos that were cloned in a previous run but still have no commits
136
- const headCheck = await $`git rev-parse HEAD`.cwd(stageDir).quiet().nothrow()
137
- const isEmptyRepo = isNewEmptyRepo || headCheck.exitCode !== 0
138
- let branchSwitched = false
139
- if (isEmptyRepo) {
140
- // Empty repo has no HEAD — create the target branch directly
141
- // (git checkout -b works even without commits as an orphan branch)
142
- await $`git checkout -b ${effectiveBranch}`.cwd(stageDir).quiet().nothrow()
143
- console.log(`Initialized branch '${effectiveBranch}' on empty repository`)
144
- branchSwitched = true
145
- } else {
146
- const currentBranch = await this.ProjectRepository.getBranch({ rootDir: stageDir })
147
- if (currentBranch !== effectiveBranch) {
148
- console.log(`Switching from branch '${currentBranch}' to '${effectiveBranch}' ...`)
149
- // Check if branch exists locally
150
- const localBranchCheck = await $`git branch --list ${effectiveBranch}`.cwd(stageDir).quiet().nothrow()
151
- const localBranchExists = localBranchCheck.text().trim().length > 0
152
-
153
- if (localBranchExists) {
154
- await $`git checkout ${effectiveBranch}`.cwd(stageDir).quiet()
155
- console.log(`Checked out existing local branch '${effectiveBranch}'`)
156
- } else {
157
- // Check if branch exists on remote
158
- const remoteBranchCheck = await $`git ls-remote --heads origin ${effectiveBranch}`.cwd(stageDir).quiet().nothrow()
159
- const remoteBranchExists = remoteBranchCheck.text().trim().length > 0
160
-
161
- if (remoteBranchExists) {
162
- await $`git checkout -b ${effectiveBranch} origin/${effectiveBranch}`.cwd(stageDir).quiet()
163
- console.log(`Checked out remote branch '${effectiveBranch}'`)
164
- } else {
165
- await $`git checkout -b ${effectiveBranch}`.cwd(stageDir).quiet()
166
- console.log(`Created new branch '${effectiveBranch}'`)
167
- }
168
- }
169
- branchSwitched = true
170
- } else if (targetBranch) {
171
- console.log(`Already on branch '${targetBranch}'`)
172
- }
173
- }
174
-
175
- // Handle --dangerously-squash-to-commit on the stage repo
215
+ // ── 11. Handle --dangerously-squash-to-commit ───────────
176
216
  let squashedToCommit = false
177
217
  const squashToCommit = ctx.options.dangerouslySquashToCommit
178
218
  if (squashToCommit) {
@@ -1,6 +1,6 @@
1
1
 
2
- import { join } from 'path'
3
- import { readFile, writeFile } from 'fs/promises'
2
+ import { join, dirname } from 'path'
3
+ import { readFile, writeFile, access } from 'fs/promises'
4
4
  import glob from 'fast-glob'
5
5
  import chalk from 'chalk'
6
6
 
@@ -64,12 +64,26 @@ export async function capsule({
64
64
  let modified = false
65
65
 
66
66
  for (const [workspaceName, publicName] of renameEntries) {
67
+ // Replace the literal workspace name
67
68
  const regex = new RegExp(workspaceName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g')
68
69
  const replaced = content.replace(regex, publicName)
69
70
  if (replaced !== content) {
70
71
  content = replaced
71
72
  modified = true
72
73
  }
74
+
75
+ // Also replace regex-escaped versions of the workspace name
76
+ // (e.g. @stream44\.studio\/encapsulate in test patterns)
77
+ const wsEscaped = workspaceName.replace(/[.*+?^${}()|[\]/\\]/g, '\\$&')
78
+ if (wsEscaped !== workspaceName) {
79
+ const pubEscaped = publicName.replace(/[.*+?^${}()|[\]/\\]/g, '\\$&')
80
+ const escapedRegex = new RegExp(wsEscaped.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g')
81
+ const replacedEscaped = content.replace(escapedRegex, pubEscaped)
82
+ if (replacedEscaped !== content) {
83
+ content = replacedEscaped
84
+ modified = true
85
+ }
86
+ }
73
87
  }
74
88
 
75
89
  if (modified) {
@@ -135,6 +149,31 @@ export async function capsule({
135
149
  }
136
150
  }
137
151
  }
152
+
153
+ // Clean up tsconfig.json extends paths that don't resolve in the published package
154
+ console.log('[t44] Cleaning up tsconfig.json extends paths ...\n')
155
+ for (const [repoName, repoSourceDir] of Object.entries(repos)) {
156
+ await cleanupTsconfigExtends(join(repoSourceDir as string, 'tsconfig.json'))
157
+
158
+ // Follow workspaces to find sub-workspace tsconfig.json files
159
+ const pkgPath = join(repoSourceDir as string, 'package.json')
160
+ try {
161
+ const pkgContent = await readFile(pkgPath, 'utf-8')
162
+ const pkg = JSON.parse(pkgContent)
163
+ const workspaces: string[] = pkg.workspaces || []
164
+ if (workspaces.length > 0) {
165
+ const tsconfigPatterns = workspaces.map(ws => join(ws, 'tsconfig.json'))
166
+ const tsconfigPaths = await glob(tsconfigPatterns, {
167
+ cwd: repoSourceDir as string,
168
+ absolute: true,
169
+ onlyFiles: true,
170
+ })
171
+ for (const tsconfigPath of tsconfigPaths) {
172
+ await cleanupTsconfigExtends(tsconfigPath)
173
+ }
174
+ }
175
+ } catch { }
176
+ }
138
177
  }
139
178
  }
140
179
  },
@@ -337,3 +376,29 @@ async function updateWorkspaceDependencies(
337
376
  }
338
377
  }
339
378
  }
379
+
380
+ async function cleanupTsconfigExtends(tsconfigPath: string): Promise<void> {
381
+ try {
382
+ const content = await readFile(tsconfigPath, 'utf-8')
383
+ const tsconfig = JSON.parse(content)
384
+
385
+ if (!tsconfig.extends) return
386
+
387
+ // Resolve the extends path relative to the tsconfig.json directory
388
+ const tsconfigDir = dirname(tsconfigPath)
389
+ const extendsPath = join(tsconfigDir, tsconfig.extends)
390
+
391
+ try {
392
+ await access(extendsPath)
393
+ // Path exists — keep it
394
+ } catch {
395
+ // Path does not exist — remove the extends field
396
+ delete tsconfig.extends
397
+ const indent = detectIndent(content)
398
+ await writeFile(tsconfigPath, JSON.stringify(tsconfig, null, indent) + '\n', 'utf-8')
399
+ console.log(chalk.green(` ✓ Removed invalid extends from ${tsconfigPath}\n`))
400
+ }
401
+ } catch {
402
+ // tsconfig.json doesn't exist or isn't valid JSON — skip
403
+ }
404
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "t44",
3
- "version": "0.4.0-rc.21",
3
+ "version": "0.4.0-rc.23",
4
4
  "license": "LGPL",
5
5
  "repository": {
6
6
  "type": "git",
@@ -67,18 +67,18 @@
67
67
  "fast-glob": "^3.3.3",
68
68
  "inquirer": "^12.4.0",
69
69
  "axios": "^1.13.4",
70
- "turbo": "^2.7.5",
71
70
  "@ucanto/principal": "^9.0.3",
72
71
  "@ucanto/server": "^11.0.3",
73
72
  "json-schema-ref-resolver": "^3.0.0",
74
- "@stream44.studio/encapsulate": "^0.4.0-rc.21"
73
+ "@stream44.studio/encapsulate": "^0.4.0-rc.23",
74
+ "turbo": "^2.7.5"
75
75
  },
76
76
  "optionalDependencies": {
77
- "@stream44.studio/t44-bunny.net": "^0.1.0-rc.3",
78
- "@stream44.studio/t44-vercel.com": "^0.1.0-rc.3",
79
- "@stream44.studio/t44-github.com": "^0.1.0-rc.3",
80
- "@stream44.studio/t44-dynadot.com": "^0.1.0-rc.3",
81
- "@stream44.studio/t44-npmjs.com": "^0.1.0-rc.3"
77
+ "@stream44.studio/t44-bunny.net": "^0.1.0-rc.4",
78
+ "@stream44.studio/t44-vercel.com": "^0.1.0-rc.4",
79
+ "@stream44.studio/t44-github.com": "^0.1.0-rc.4",
80
+ "@stream44.studio/t44-dynadot.com": "^0.1.0-rc.4",
81
+ "@stream44.studio/t44-npmjs.com": "^0.1.0-rc.4"
82
82
  },
83
83
  "devDependencies": {
84
84
  "@types/bun": "^1.3.4",
package/tsconfig.json CHANGED
@@ -1,5 +1,4 @@
1
1
  {
2
- "extends": "../../../tsconfig.paths.json",
3
2
  "compilerOptions": {
4
3
  "target": "es2021",
5
4
  "module": "esnext",
@@ -31,4 +30,4 @@
31
30
  "exclude": [
32
31
  "node_modules"
33
32
  ]
34
- }
33
+ }