@stacksjs/defaults 0.72.85 → 0.72.89
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/app/Actions/Dashboard/Operations/MigrationReconcileAction.ts +4 -1
- package/app/Actions/Dashboard/Operations/cached-computation.test.ts +128 -0
- package/app/Actions/Dashboard/Operations/cached-computation.ts +76 -0
- package/app/Actions/Dashboard/Operations/migration-operations.test.ts +60 -0
- package/app/Actions/Dashboard/Operations/migration-operations.ts +82 -3
- package/app/Actions/Locale/SetLocaleAction.ts +2 -2
- package/ide/vscode/package.json +1 -1
- package/package.json +2 -2
- package/project/bootstrap +8 -2
|
@@ -12,7 +12,10 @@ export default new Action({
|
|
|
12
12
|
apiResponse: true,
|
|
13
13
|
async handle(request: RequestInstance) {
|
|
14
14
|
const input = request.all() as Record<string, unknown>
|
|
15
|
-
|
|
15
|
+
// Fresh, not cached: the revision below is an optimistic-concurrency gate,
|
|
16
|
+
// and checking a caller's token against a plan computed up to a TTL ago
|
|
17
|
+
// would admit exactly the drift the gate is here to catch.
|
|
18
|
+
const plan = await migrationPlan({ fresh: true })
|
|
16
19
|
if (stringValue(input.revision) !== plan.revision)
|
|
17
20
|
return response.json({ message: 'The migration state changed. Refresh before reconciling.' }, 409)
|
|
18
21
|
if (stringValue(input.confirmation) !== `reconcile ${plan.environment}`)
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import { cachedComputation } from './cached-computation'
|
|
3
|
+
|
|
4
|
+
function counter() {
|
|
5
|
+
let calls = 0
|
|
6
|
+
let clock = 1_000
|
|
7
|
+
const cache = cachedComputation({
|
|
8
|
+
ttlMs: 30_000,
|
|
9
|
+
now: () => clock,
|
|
10
|
+
compute: async () => {
|
|
11
|
+
calls += 1
|
|
12
|
+
return `value-${calls}`
|
|
13
|
+
},
|
|
14
|
+
})
|
|
15
|
+
return {
|
|
16
|
+
cache,
|
|
17
|
+
calls: () => calls,
|
|
18
|
+
advance: (ms: number) => { clock += ms },
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('cachedComputation', () => {
|
|
23
|
+
it('computes once and serves the same value inside the window', async () => {
|
|
24
|
+
const { cache, calls } = counter()
|
|
25
|
+
|
|
26
|
+
expect(await cache.get()).toBe('value-1')
|
|
27
|
+
expect(await cache.get()).toBe('value-1')
|
|
28
|
+
expect(await cache.get()).toBe('value-1')
|
|
29
|
+
expect(calls()).toBe(1)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('recomputes once the value has aged past the TTL', async () => {
|
|
33
|
+
const { cache, calls, advance } = counter()
|
|
34
|
+
|
|
35
|
+
await cache.get()
|
|
36
|
+
advance(29_999)
|
|
37
|
+
expect(await cache.get()).toBe('value-1')
|
|
38
|
+
expect(calls()).toBe(1)
|
|
39
|
+
|
|
40
|
+
advance(1)
|
|
41
|
+
expect(await cache.get()).toBe('value-2')
|
|
42
|
+
expect(calls()).toBe(2)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('recomputes on demand and reseeds the cache with that value', async () => {
|
|
46
|
+
const { cache, calls } = counter()
|
|
47
|
+
|
|
48
|
+
await cache.get()
|
|
49
|
+
expect(await cache.get({ fresh: true })).toBe('value-2')
|
|
50
|
+
expect(calls()).toBe(2)
|
|
51
|
+
|
|
52
|
+
// The forced read is not a one-off: later readers get what it computed,
|
|
53
|
+
// rather than the value it replaced.
|
|
54
|
+
expect(await cache.get()).toBe('value-2')
|
|
55
|
+
expect(calls()).toBe(2)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('recomputes after an explicit invalidation', async () => {
|
|
59
|
+
const { cache, calls } = counter()
|
|
60
|
+
|
|
61
|
+
await cache.get()
|
|
62
|
+
cache.invalidate()
|
|
63
|
+
expect(await cache.get()).toBe('value-2')
|
|
64
|
+
expect(calls()).toBe(2)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('collapses concurrent readers onto one computation', async () => {
|
|
68
|
+
// The case this exists for: both operations pages loading at once used to
|
|
69
|
+
// run the same model-versus-schema diff twice for identical output.
|
|
70
|
+
let calls = 0
|
|
71
|
+
let release: (value: string) => void = () => {}
|
|
72
|
+
const gate = new Promise<string>((resolve) => { release = resolve })
|
|
73
|
+
const cache = cachedComputation({
|
|
74
|
+
ttlMs: 30_000,
|
|
75
|
+
compute: async () => {
|
|
76
|
+
calls += 1
|
|
77
|
+
return await gate
|
|
78
|
+
},
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
const readers = [cache.get(), cache.get(), cache.get()]
|
|
82
|
+
release('shared')
|
|
83
|
+
|
|
84
|
+
expect(await Promise.all(readers)).toEqual(['shared', 'shared', 'shared'])
|
|
85
|
+
expect(calls).toBe(1)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('does not wedge later readers onto a computation that failed', async () => {
|
|
89
|
+
let attempt = 0
|
|
90
|
+
const cache = cachedComputation({
|
|
91
|
+
ttlMs: 30_000,
|
|
92
|
+
compute: async () => {
|
|
93
|
+
attempt += 1
|
|
94
|
+
if (attempt === 1)
|
|
95
|
+
throw new Error('first attempt failed')
|
|
96
|
+
return 'recovered'
|
|
97
|
+
},
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
await expect(cache.get()).rejects.toThrow('first attempt failed')
|
|
101
|
+
expect(await cache.get()).toBe('recovered')
|
|
102
|
+
expect(attempt).toBe(2)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('does not let a forced read adopt a computation that started before it', async () => {
|
|
106
|
+
// `fresh` exists for callers gating a write on the answer. Joining a read
|
|
107
|
+
// already in flight would hand back a value computed before whatever made
|
|
108
|
+
// them ask, which is the staleness the flag is there to avoid.
|
|
109
|
+
let calls = 0
|
|
110
|
+
const releases: Array<(value: string) => void> = []
|
|
111
|
+
const cache = cachedComputation({
|
|
112
|
+
ttlMs: 30_000,
|
|
113
|
+
compute: async () => {
|
|
114
|
+
calls += 1
|
|
115
|
+
return await new Promise<string>((resolve) => { releases.push(resolve) })
|
|
116
|
+
},
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
const slowReader = cache.get()
|
|
120
|
+
const forced = cache.get({ fresh: true })
|
|
121
|
+
expect(calls).toBe(2)
|
|
122
|
+
|
|
123
|
+
releases[0]('stale')
|
|
124
|
+
releases[1]('current')
|
|
125
|
+
expect(await slowReader).toBe('stale')
|
|
126
|
+
expect(await forced).toBe('current')
|
|
127
|
+
})
|
|
128
|
+
})
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A single cached value with a time budget and no duplicate work in flight.
|
|
3
|
+
*
|
|
4
|
+
* Written for the dashboard's migration plan, which costs a full
|
|
5
|
+
* model-versus-schema diff plus a ledger audit to produce. Two read endpoints
|
|
6
|
+
* built one per request, so a page load cost seconds and moving between the
|
|
7
|
+
* two pages paid it again.
|
|
8
|
+
*
|
|
9
|
+
* Kept separate from the plan itself so the caching rules can be tested
|
|
10
|
+
* without a database behind them: pass a `now` and the TTL boundary is exact
|
|
11
|
+
* rather than a sleep.
|
|
12
|
+
*/
|
|
13
|
+
export interface CachedComputationOptions<T> {
|
|
14
|
+
/** How long a computed value may be handed to a reader, in milliseconds. */
|
|
15
|
+
ttlMs: number
|
|
16
|
+
compute: () => Promise<T>
|
|
17
|
+
/** Injectable clock. Tests drive the TTL boundary with it. */
|
|
18
|
+
now?: () => number
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface CachedComputation<T> {
|
|
22
|
+
/**
|
|
23
|
+
* Return the cached value, computing one if there is none or it has aged
|
|
24
|
+
* out. `fresh` forces a recomputation and reseeds the cache with it - for
|
|
25
|
+
* callers whose answer gates a write and so cannot be a moment old.
|
|
26
|
+
*/
|
|
27
|
+
get: (options?: { fresh?: boolean }) => Promise<T>
|
|
28
|
+
/** Forget the cached value. The next `get` recomputes. */
|
|
29
|
+
invalidate: () => void
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function cachedComputation<T>(options: CachedComputationOptions<T>): CachedComputation<T> {
|
|
33
|
+
const now = options.now ?? Date.now
|
|
34
|
+
let cached: { value: T, computedAt: number } | null = null
|
|
35
|
+
let inFlight: Promise<T> | null = null
|
|
36
|
+
|
|
37
|
+
async function run(): Promise<T> {
|
|
38
|
+
// Callers arriving mid-computation join the one already running instead of
|
|
39
|
+
// starting a second. Without this the two operations pages loading
|
|
40
|
+
// together ran the same expensive diff concurrently for identical output.
|
|
41
|
+
if (inFlight)
|
|
42
|
+
return await inFlight
|
|
43
|
+
|
|
44
|
+
inFlight = options.compute()
|
|
45
|
+
try {
|
|
46
|
+
const value = await inFlight
|
|
47
|
+
cached = { value, computedAt: now() }
|
|
48
|
+
return value
|
|
49
|
+
}
|
|
50
|
+
finally {
|
|
51
|
+
// Cleared on failure too, so one rejected computation does not wedge
|
|
52
|
+
// every later caller onto the same rejected promise.
|
|
53
|
+
inFlight = null
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
async get(getOptions: { fresh?: boolean } = {}): Promise<T> {
|
|
59
|
+
if (getOptions.fresh) {
|
|
60
|
+
cached = null
|
|
61
|
+
// Deliberately not joined to an in-flight read: that one may have
|
|
62
|
+
// started before whatever made this caller ask for a fresh value.
|
|
63
|
+
inFlight = null
|
|
64
|
+
return await run()
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (cached && now() - cached.computedAt < options.ttlMs)
|
|
68
|
+
return cached.value
|
|
69
|
+
|
|
70
|
+
return await run()
|
|
71
|
+
},
|
|
72
|
+
invalidate(): void {
|
|
73
|
+
cached = null
|
|
74
|
+
},
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import { readFileSync } from 'node:fs'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The migration plan is cached, so which callers opt out of that cache is a
|
|
7
|
+
* correctness property rather than a preference.
|
|
8
|
+
*
|
|
9
|
+
* `revision` is an optimistic-concurrency token: a caller sends back the
|
|
10
|
+
* revision it reviewed, and the server refuses the write if the plan has
|
|
11
|
+
* moved since. Compare that token against a plan computed up to a TTL ago and
|
|
12
|
+
* the gate admits exactly the drift it exists to catch. These read the source
|
|
13
|
+
* because the alternative is standing a database up to prove a call shape.
|
|
14
|
+
*/
|
|
15
|
+
const directory = import.meta.dir
|
|
16
|
+
|
|
17
|
+
function source(file: string): string {
|
|
18
|
+
return readFileSync(join(directory, file), 'utf8')
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('migration plan cache wiring', () => {
|
|
22
|
+
it('gates every write on a freshly computed plan', () => {
|
|
23
|
+
const gates = [
|
|
24
|
+
['migration-operations.ts', 'applyMigrationPlan'],
|
|
25
|
+
['MigrationReconcileAction.ts', 'MigrationReconcileAction'],
|
|
26
|
+
] as const
|
|
27
|
+
|
|
28
|
+
for (const [file, gate] of gates) {
|
|
29
|
+
const text = source(file)
|
|
30
|
+
const calls = [...text.matchAll(/migrationPlan\(([^)]*)\)/g)]
|
|
31
|
+
.map(match => match[1].trim())
|
|
32
|
+
// The declaration itself, not a call.
|
|
33
|
+
.filter(argument => !argument.startsWith('options:'))
|
|
34
|
+
|
|
35
|
+
expect(calls.length, `${gate} should still ask for a plan`).toBeGreaterThan(0)
|
|
36
|
+
for (const argument of calls)
|
|
37
|
+
expect(argument, `${gate} must gate on a fresh plan`).toContain('fresh: true')
|
|
38
|
+
}
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('lets the read-only index endpoints use the cache', () => {
|
|
42
|
+
for (const file of ['ChangeIndexAction.ts', 'MigrationIndexAction.ts']) {
|
|
43
|
+
const calls = [...source(file).matchAll(/migrationPlan\(([^)]*)\)/g)].map(match => match[1].trim())
|
|
44
|
+
expect(calls.length, `${file} should still ask for a plan`).toBeGreaterThan(0)
|
|
45
|
+
for (const argument of calls)
|
|
46
|
+
expect(argument, `${file} should not force a recomputation per request`).toBe('')
|
|
47
|
+
}
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('drops the cached state after anything that moves the schema or ledger', () => {
|
|
51
|
+
const text = source('migration-operations.ts')
|
|
52
|
+
|
|
53
|
+
// After the migrate subprocess succeeds, and after a real (non-dry-run)
|
|
54
|
+
// ledger reconcile. Either one leaves the cached reading describing a
|
|
55
|
+
// state that no longer exists.
|
|
56
|
+
const invalidations = text.match(/invalidateMigrationPlan\(\)/g) ?? []
|
|
57
|
+
expect(invalidations.length).toBeGreaterThanOrEqual(3)
|
|
58
|
+
expect(text).toContain('reconcileMigrationLedger({ dryRun: false })')
|
|
59
|
+
})
|
|
60
|
+
})
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import process from 'node:process'
|
|
2
2
|
import { auditMigrationLedger, previewPendingMigrations, reconcileMigrationLedger } from '@stacksjs/database'
|
|
3
|
+
import { cachedComputation } from './cached-computation'
|
|
3
4
|
|
|
4
5
|
export interface MigrationPlanOperation {
|
|
5
6
|
kind: string
|
|
@@ -18,7 +19,55 @@ export class MigrationOperationError extends Error {
|
|
|
18
19
|
|
|
19
20
|
const destructiveKinds = new Set(['drop_table', 'drop_column', 'alter_column', 'truncate_table'])
|
|
20
21
|
|
|
21
|
-
export
|
|
22
|
+
export type MigrationPlan = Awaited<ReturnType<typeof computeMigrationPlan>>
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* How long a computed plan may be served to a reader.
|
|
26
|
+
*
|
|
27
|
+
* Building one costs a full model-versus-schema diff plus a ledger audit -
|
|
28
|
+
* measured at 2.3s and 1.4s against 93 models, versus 14ms for a normal
|
|
29
|
+
* dashboard endpoint. `/operations/migrations` and `/operations/changes` both
|
|
30
|
+
* built one per request, so opening either page cost seconds and moving
|
|
31
|
+
* between them paid it twice.
|
|
32
|
+
*
|
|
33
|
+
* Thirty seconds is long enough to cover a page load, a tab switch between
|
|
34
|
+
* the two operations pages, and a couple of refreshes, and short enough that
|
|
35
|
+
* editing a model and reloading shows the new plan without a manual step.
|
|
36
|
+
* Anything that changes the schema from inside the dashboard invalidates
|
|
37
|
+
* explicitly, so the window only ever hides changes made elsewhere.
|
|
38
|
+
*/
|
|
39
|
+
const PLAN_TTL_MS = 30_000
|
|
40
|
+
|
|
41
|
+
const planCache = cachedComputation({ ttlMs: PLAN_TTL_MS, compute: () => computeMigrationPlan() })
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The ledger repair preview, cached on the same terms as the plan.
|
|
45
|
+
*
|
|
46
|
+
* `MigrationIndexAction` asks for both on every request, and a dry-run
|
|
47
|
+
* reconcile reads the same ledger the plan already audited - about 1.3s of
|
|
48
|
+
* the endpoint's cost, for a report that cannot have changed while the plan
|
|
49
|
+
* it sits next to has not.
|
|
50
|
+
*/
|
|
51
|
+
const reconciliationCache = cachedComputation({
|
|
52
|
+
ttlMs: PLAN_TTL_MS,
|
|
53
|
+
compute: () => reconcileMigrationLedger({ dryRun: true }),
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Drop the cached view of migration state.
|
|
58
|
+
*
|
|
59
|
+
* Applying a migration or reconciling the ledger changes the very state these
|
|
60
|
+
* describe, so holding them for the rest of the TTL would show an operator
|
|
61
|
+
* the work they just did as still pending. Both go together: the plan and the
|
|
62
|
+
* repair preview are two readings of one state, and keeping one while
|
|
63
|
+
* dropping the other would put the page into a shape neither describes.
|
|
64
|
+
*/
|
|
65
|
+
export function invalidateMigrationPlan(): void {
|
|
66
|
+
planCache.invalidate()
|
|
67
|
+
reconciliationCache.invalidate()
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function computeMigrationPlan() {
|
|
22
71
|
const [rawOperations, ledger] = await Promise.all([
|
|
23
72
|
previewPendingMigrations(),
|
|
24
73
|
auditMigrationLedger(),
|
|
@@ -41,6 +90,11 @@ export async function migrationPlan() {
|
|
|
41
90
|
revision,
|
|
42
91
|
applied: ledger.recordedCount,
|
|
43
92
|
ledger,
|
|
93
|
+
// Stamped so a reader can tell how old the plan it is looking at is.
|
|
94
|
+
// Deliberately outside the `revision` hash above: revision identifies the
|
|
95
|
+
// schema state being approved, and must not change just because the same
|
|
96
|
+
// state was computed again a minute later.
|
|
97
|
+
computedAt: new Date().toISOString(),
|
|
44
98
|
summary: {
|
|
45
99
|
pending: operations.length,
|
|
46
100
|
destructive: operations.filter(operation => operation.destructive).length,
|
|
@@ -50,8 +104,20 @@ export async function migrationPlan() {
|
|
|
50
104
|
}
|
|
51
105
|
}
|
|
52
106
|
|
|
107
|
+
/**
|
|
108
|
+
* The model-derived schema plan and the migration ledger's health.
|
|
109
|
+
*
|
|
110
|
+
* Served from a short-lived cache by default. Pass `fresh` when the answer is
|
|
111
|
+
* about to gate a write: `revision` is an optimistic-concurrency token, and
|
|
112
|
+
* comparing a caller's token against a cached plan would let a change that
|
|
113
|
+
* landed during the window through the gate it exists to close.
|
|
114
|
+
*/
|
|
115
|
+
export async function migrationPlan(options: { fresh?: boolean } = {}): Promise<MigrationPlan> {
|
|
116
|
+
return await planCache.get(options)
|
|
117
|
+
}
|
|
118
|
+
|
|
53
119
|
export async function applyMigrationPlan(input: { revision: string, confirmation: string }): Promise<{ message: string }> {
|
|
54
|
-
const plan = await migrationPlan()
|
|
120
|
+
const plan = await migrationPlan({ fresh: true })
|
|
55
121
|
if (input.revision !== plan.revision)
|
|
56
122
|
throw new MigrationOperationError('The migration plan changed. Refresh and review the new plan before applying it.')
|
|
57
123
|
if (input.confirmation !== `migrate ${plan.environment}`)
|
|
@@ -72,9 +138,22 @@ export async function applyMigrationPlan(input: { revision: string, confirmation
|
|
|
72
138
|
])
|
|
73
139
|
if (exitCode !== 0)
|
|
74
140
|
throw new MigrationOperationError(stderr.trim() || stdout.trim() || 'The migration command failed.')
|
|
141
|
+
// The schema just moved, so every cached plan describing the old one is
|
|
142
|
+
// wrong. Without this the operations pages would keep showing the applied
|
|
143
|
+
// work as pending for the rest of the TTL.
|
|
144
|
+
invalidateMigrationPlan()
|
|
75
145
|
return { message: `Applied ${plan.operations.length} planned schema operation${plan.operations.length === 1 ? '' : 's'}.` }
|
|
76
146
|
}
|
|
77
147
|
|
|
78
148
|
export async function reconcileMigrationLedgerPlan(apply = false) {
|
|
79
|
-
|
|
149
|
+
// A dry run reports what it would repair and changes nothing, so it is
|
|
150
|
+
// cacheable. A real one rewrites the ledger that the plan's `applied`,
|
|
151
|
+
// `drift` and `ledgerIssues` are read from, so it runs for real every time
|
|
152
|
+
// and drops what the old ledger produced.
|
|
153
|
+
if (!apply)
|
|
154
|
+
return await reconciliationCache.get()
|
|
155
|
+
|
|
156
|
+
const result = await reconcileMigrationLedger({ dryRun: false })
|
|
157
|
+
invalidateMigrationPlan()
|
|
158
|
+
return result
|
|
80
159
|
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { createLocaleSwitchResponse } from '@stacksjs/i18n'
|
|
2
2
|
import { existsSync } from 'node:fs'
|
|
3
|
-
import {
|
|
3
|
+
import { siteConfigPath } from '@stacksjs/path'
|
|
4
4
|
|
|
5
5
|
async function loadSiteI18n(): Promise<{ locales: string[], defaultLocale: string } | null> {
|
|
6
|
-
const sitePath =
|
|
6
|
+
const sitePath = siteConfigPath()
|
|
7
7
|
if (!existsSync(sitePath))
|
|
8
8
|
return null
|
|
9
9
|
|
package/ide/vscode/package.json
CHANGED
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@stacksjs/defaults",
|
|
3
3
|
"type": "module",
|
|
4
4
|
"sideEffects": false,
|
|
5
|
-
"version": "0.72.
|
|
5
|
+
"version": "0.72.89",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
8
8
|
"url": "git+https://github.com/stacksjs/stacks.git",
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
"dependencies": {
|
|
52
52
|
"@iconify-json/f7": "^1.2.2",
|
|
53
53
|
"@iconify-json/hugeicons": "^1.2.27",
|
|
54
|
-
"@stacksjs/mobile": "^0.72.
|
|
54
|
+
"@stacksjs/mobile": "^0.72.89",
|
|
55
55
|
"@stacksjs/sanitizer": "^0.2.113"
|
|
56
56
|
},
|
|
57
57
|
"scripts": {
|
package/project/bootstrap
CHANGED
|
@@ -92,10 +92,16 @@ ensure_pantry() {
|
|
|
92
92
|
echo "Pantry installed to ${INSTALL_DIR}/pantry"
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
+
is_stacks_app() {
|
|
96
|
+
[ -f "package.json" ] \
|
|
97
|
+
&& [ -x "buddy" ] \
|
|
98
|
+
&& grep -Eq '"stacks"[[:space:]]*:' package.json
|
|
99
|
+
}
|
|
100
|
+
|
|
95
101
|
# --- Main ---
|
|
96
102
|
|
|
97
103
|
# If we are outside a project, create one from the latest stable release.
|
|
98
|
-
if [ ! -d "storage/framework/core" ]; then
|
|
104
|
+
if [ ! -d "storage/framework/core" ] && ! is_stacks_app; then
|
|
99
105
|
if [ -z "$PROJECT_NAME" ]; then
|
|
100
106
|
PROJECT_NAME="${STACKS_PROJECT:-stacks}"
|
|
101
107
|
|
|
@@ -142,7 +148,7 @@ if [ ! -d "storage/framework/core" ]; then
|
|
|
142
148
|
fi
|
|
143
149
|
|
|
144
150
|
# Detect if we're in a Stacks project
|
|
145
|
-
if [ ! -d "storage/framework/core" ]; then
|
|
151
|
+
if [ ! -d "storage/framework/core" ] && ! is_stacks_app; then
|
|
146
152
|
echo "Error: not inside a Stacks project."
|
|
147
153
|
exit 1
|
|
148
154
|
fi
|