@supacloud/cli 0.96.2 → 0.97.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js
CHANGED
|
@@ -24387,6 +24387,302 @@ test("${name} controller preserves an asynchronous read failure", async () => {
|
|
|
24387
24387
|
};
|
|
24388
24388
|
}
|
|
24389
24389
|
|
|
24390
|
+
// src/shared/tools/app-feature.ts
|
|
24391
|
+
function featureScaffold(name, className, governance) {
|
|
24392
|
+
const commandName = `${name}.approve`;
|
|
24393
|
+
const { permission, rpc } = governance;
|
|
24394
|
+
return {
|
|
24395
|
+
[`${name}.contracts.ts`]: `import { t } from "elysia";
|
|
24396
|
+
|
|
24397
|
+
export const ${className}Params = t.Object({
|
|
24398
|
+
id: t.String({ minLength: 1, maxLength: 128, pattern: "^[A-Za-z0-9_-]+$" }),
|
|
24399
|
+
});
|
|
24400
|
+
export const ${className}ListQuery = t.Object({
|
|
24401
|
+
state: t.Optional(t.String({ minLength: 1, maxLength: 64 })),
|
|
24402
|
+
});
|
|
24403
|
+
export const ${className}ApproveBody = t.Object({
|
|
24404
|
+
expectedVersion: t.Integer({ minimum: 1 }),
|
|
24405
|
+
reason: t.Optional(t.String({ maxLength: 2000 })),
|
|
24406
|
+
});
|
|
24407
|
+
export const ${className}Row = t.Object({
|
|
24408
|
+
id: t.String(),
|
|
24409
|
+
state: t.String(),
|
|
24410
|
+
version: t.Integer({ minimum: 1 }),
|
|
24411
|
+
});
|
|
24412
|
+
export const ${className}ListResponse = t.Array(${className}Row);
|
|
24413
|
+
`,
|
|
24414
|
+
[`${name}.model.ts`]: `import { InjectionToken } from "@supacloud/app/core";
|
|
24415
|
+
import type {
|
|
24416
|
+
${className}ApproveBody,
|
|
24417
|
+
${className}ListQuery,
|
|
24418
|
+
${className}Row,
|
|
24419
|
+
} from "./${name}.contracts";
|
|
24420
|
+
import type { StaticDecode } from "typebox";
|
|
24421
|
+
|
|
24422
|
+
export type ${className}ListInput = StaticDecode<typeof ${className}ListQuery>;
|
|
24423
|
+
export type ${className}ApproveInput = StaticDecode<typeof ${className}ApproveBody>;
|
|
24424
|
+
export type ${className}Result = StaticDecode<typeof ${className}Row>;
|
|
24425
|
+
|
|
24426
|
+
export interface ${className}ReadPort {
|
|
24427
|
+
list(input: ${className}ListInput): Promise<${className}Result[]>;
|
|
24428
|
+
detail(id: string): Promise<${className}Result>;
|
|
24429
|
+
}
|
|
24430
|
+
|
|
24431
|
+
export interface ${className}WritePort {
|
|
24432
|
+
approve(input: {
|
|
24433
|
+
id: string;
|
|
24434
|
+
expectedVersion: number;
|
|
24435
|
+
reason?: string;
|
|
24436
|
+
}): Promise<${className}Result>;
|
|
24437
|
+
}
|
|
24438
|
+
|
|
24439
|
+
export const ${className}Reader = new InjectionToken<${className}ReadPort>(
|
|
24440
|
+
"${name}.reader", { scope: "request" },
|
|
24441
|
+
);
|
|
24442
|
+
export const ${className}Writer = new InjectionToken<${className}WritePort>(
|
|
24443
|
+
"${name}.writer", { scope: "request" },
|
|
24444
|
+
);
|
|
24445
|
+
`,
|
|
24446
|
+
[`${name}.service.ts`]: `import { Inject, Injectable, Optional } from "@supacloud/app/core";
|
|
24447
|
+
import {
|
|
24448
|
+
${className}Reader,
|
|
24449
|
+
${className}Writer,
|
|
24450
|
+
type ${className}ApproveInput,
|
|
24451
|
+
type ${className}ReadPort,
|
|
24452
|
+
type ${className}Result,
|
|
24453
|
+
type ${className}WritePort,
|
|
24454
|
+
} from "./${name}.model";
|
|
24455
|
+
|
|
24456
|
+
@Injectable({ scope: "request" })
|
|
24457
|
+
export class ${className}Service {
|
|
24458
|
+
constructor(
|
|
24459
|
+
@Inject(${className}Reader) @Optional() private readonly reader?: ${className}ReadPort,
|
|
24460
|
+
@Inject(${className}Writer) @Optional() private readonly writer?: ${className}WritePort,
|
|
24461
|
+
) {}
|
|
24462
|
+
|
|
24463
|
+
async list(input: Parameters<${className}ReadPort["list"]>[0]): Promise<${className}Result[]> {
|
|
24464
|
+
if (!this.reader) throw new Error("Bind ${className}Reader with verified identity and tenant scope");
|
|
24465
|
+
return this.reader.list(input);
|
|
24466
|
+
}
|
|
24467
|
+
|
|
24468
|
+
async detail(id: string): Promise<${className}Result> {
|
|
24469
|
+
if (!this.reader) throw new Error("Bind ${className}Reader with verified identity and tenant scope");
|
|
24470
|
+
return this.reader.detail(id);
|
|
24471
|
+
}
|
|
24472
|
+
|
|
24473
|
+
async approve(id: string, input: ${className}ApproveInput): Promise<${className}Result> {
|
|
24474
|
+
if (!this.writer) throw new Error("Bind ${className}Writer with durable command governance");
|
|
24475
|
+
return this.writer.approve({ ...input, id });
|
|
24476
|
+
}
|
|
24477
|
+
}
|
|
24478
|
+
`,
|
|
24479
|
+
[`${name}.feature.ts`]: `import { Effect } from "effect";
|
|
24480
|
+
import {
|
|
24481
|
+
Body, Command, Controller, defineFeatureSlice, defineFeatureSpec, Get, Inject,
|
|
24482
|
+
Injectable, Job, Param, Post, Query, type FeatureState,
|
|
24483
|
+
} from "@supacloud/app/core";
|
|
24484
|
+
import {
|
|
24485
|
+
${className}ApproveBody,
|
|
24486
|
+
${className}ListQuery,
|
|
24487
|
+
${className}ListResponse,
|
|
24488
|
+
${className}Params,
|
|
24489
|
+
${className}Row,
|
|
24490
|
+
} from "./${name}.contracts";
|
|
24491
|
+
import { ${className}Service } from "./${name}.service";
|
|
24492
|
+
import type { ${className}ApproveInput, ${className}ListInput, ${className}Result } from "./${name}.model";
|
|
24493
|
+
|
|
24494
|
+
export const featureSpec = defineFeatureSpec({
|
|
24495
|
+
name: ${JSON.stringify(name)},
|
|
24496
|
+
states: ["draft", "approved"],
|
|
24497
|
+
transitions: {
|
|
24498
|
+
approve: {
|
|
24499
|
+
from: "draft", to: "approved", command: "Approve${className}Command",
|
|
24500
|
+
permission: ${JSON.stringify(permission)}, transaction: "required",
|
|
24501
|
+
idempotency: "required", audit: ${JSON.stringify(`${name}.approved`)},
|
|
24502
|
+
route: "POST /${name}/:id/approve",
|
|
24503
|
+
},
|
|
24504
|
+
},
|
|
24505
|
+
});
|
|
24506
|
+
export type ${className}State = FeatureState<typeof featureSpec>;
|
|
24507
|
+
|
|
24508
|
+
@Injectable({ scope: "request" })
|
|
24509
|
+
@Query({ name: "${name}.list" })
|
|
24510
|
+
export class List${className}Query {
|
|
24511
|
+
constructor(@Inject(${className}Service) private readonly service: ${className}Service) {}
|
|
24512
|
+
execute(input: ${className}ListInput): Promise<${className}Result[]> {
|
|
24513
|
+
return this.service.list(input);
|
|
24514
|
+
}
|
|
24515
|
+
}
|
|
24516
|
+
|
|
24517
|
+
@Injectable({ scope: "request" })
|
|
24518
|
+
@Command({
|
|
24519
|
+
name: ${JSON.stringify(commandName)},
|
|
24520
|
+
permission: ${JSON.stringify(permission)},
|
|
24521
|
+
transaction: "required",
|
|
24522
|
+
audit: ${JSON.stringify(`${name}.approved`)},
|
|
24523
|
+
idempotency: "required",
|
|
24524
|
+
rpc: ${JSON.stringify(rpc)},
|
|
24525
|
+
})
|
|
24526
|
+
export class Approve${className}Command {
|
|
24527
|
+
constructor(@Inject(${className}Service) private readonly service: ${className}Service) {}
|
|
24528
|
+
execute(id: string, input: ${className}ApproveInput): Promise<${className}Result> {
|
|
24529
|
+
return this.service.approve(id, input);
|
|
24530
|
+
}
|
|
24531
|
+
}
|
|
24532
|
+
|
|
24533
|
+
@Job({
|
|
24534
|
+
name: ${JSON.stringify(`${name}.refresh`)},
|
|
24535
|
+
mode: "task",
|
|
24536
|
+
idempotency: "required",
|
|
24537
|
+
})
|
|
24538
|
+
export class Refresh${className}Job {
|
|
24539
|
+
run(_input: { id: string; version: number }): Promise<void> {
|
|
24540
|
+
throw new Error("Bind the existing Workflow/Queue adapter before dispatching ${name}.refresh");
|
|
24541
|
+
}
|
|
24542
|
+
}
|
|
24543
|
+
|
|
24544
|
+
@Controller("/${name}")
|
|
24545
|
+
export class ${className}Controller {
|
|
24546
|
+
constructor(
|
|
24547
|
+
@Inject(List${className}Query) private readonly listQuery: List${className}Query,
|
|
24548
|
+
@Inject(Approve${className}Command) private readonly approveCommand: Approve${className}Command,
|
|
24549
|
+
@Inject(${className}Service) private readonly service: ${className}Service,
|
|
24550
|
+
) {}
|
|
24551
|
+
|
|
24552
|
+
@Get("/", {
|
|
24553
|
+
query: ${className}ListQuery,
|
|
24554
|
+
responses: { 200: ${className}ListResponse },
|
|
24555
|
+
effect: { required: true, dependencies: [], errors: [], retry: "none" },
|
|
24556
|
+
})
|
|
24557
|
+
list(@Query("state") state?: string): Effect.Effect<${className}Result[], never, never> {
|
|
24558
|
+
return Effect.promise(() => this.listQuery.execute(state === undefined ? {} : { state }));
|
|
24559
|
+
}
|
|
24560
|
+
|
|
24561
|
+
@Get("/:id", {
|
|
24562
|
+
params: ${className}Params,
|
|
24563
|
+
responses: { 200: ${className}Row },
|
|
24564
|
+
effect: { required: true, dependencies: [], errors: [], retry: "none" },
|
|
24565
|
+
})
|
|
24566
|
+
detail(@Param("id") id: string): Effect.Effect<${className}Result, never, never> {
|
|
24567
|
+
return Effect.promise(() => this.service.detail(id));
|
|
24568
|
+
}
|
|
24569
|
+
|
|
24570
|
+
@Post("/:id/approve", {
|
|
24571
|
+
params: ${className}Params,
|
|
24572
|
+
body: ${className}ApproveBody,
|
|
24573
|
+
responses: { 200: ${className}Row },
|
|
24574
|
+
command: Approve${className}Command,
|
|
24575
|
+
effect: { required: true, dependencies: [], errors: [], retry: "none" },
|
|
24576
|
+
})
|
|
24577
|
+
approve(
|
|
24578
|
+
@Param("id") id: string,
|
|
24579
|
+
@Body() body: ${className}ApproveInput,
|
|
24580
|
+
): Effect.Effect<${className}Result, never, never> {
|
|
24581
|
+
return Effect.promise(() => this.approveCommand.execute(id, body));
|
|
24582
|
+
}
|
|
24583
|
+
}
|
|
24584
|
+
|
|
24585
|
+
export const ${className}Feature = defineFeatureSlice({
|
|
24586
|
+
name: ${JSON.stringify(name)},
|
|
24587
|
+
tags: ["type:feature"],
|
|
24588
|
+
spec: featureSpec,
|
|
24589
|
+
providers: [${className}Service, List${className}Query, Approve${className}Command],
|
|
24590
|
+
controllers: [${className}Controller],
|
|
24591
|
+
jobs: [Refresh${className}Job],
|
|
24592
|
+
});
|
|
24593
|
+
`,
|
|
24594
|
+
[`${name}.feature.test.ts`]: `import { expect, test } from "bun:test";
|
|
24595
|
+
import { ${className}Service } from "./${name}.service";
|
|
24596
|
+
|
|
24597
|
+
test("${name} fails closed until verified read and durable write ports are bound", async () => {
|
|
24598
|
+
const service = new ${className}Service();
|
|
24599
|
+
await expect(service.list({})).rejects.toThrow("Bind ${className}Reader");
|
|
24600
|
+
await expect(service.detail("example")).rejects.toThrow("Bind ${className}Reader");
|
|
24601
|
+
await expect(service.approve("example", { expectedVersion: 1 })).rejects.toThrow("Bind ${className}Writer");
|
|
24602
|
+
});
|
|
24603
|
+
|
|
24604
|
+
test("${name} preserves denied reads and rejected writes without fallback or retry", async () => {
|
|
24605
|
+
const denied = new Error("denied");
|
|
24606
|
+
let attempts = 0;
|
|
24607
|
+
const service = new ${className}Service({
|
|
24608
|
+
list: async () => { throw denied; },
|
|
24609
|
+
detail: async () => { throw denied; },
|
|
24610
|
+
}, {
|
|
24611
|
+
approve: async () => { attempts++; throw denied; },
|
|
24612
|
+
});
|
|
24613
|
+
await expect(service.list({})).rejects.toBe(denied);
|
|
24614
|
+
await expect(service.detail("example")).rejects.toBe(denied);
|
|
24615
|
+
await expect(service.approve("example", { expectedVersion: 1 })).rejects.toBe(denied);
|
|
24616
|
+
expect(attempts).toBe(1);
|
|
24617
|
+
});
|
|
24618
|
+
|
|
24619
|
+
test("${name} forwards the authoritative version and result without inventing persistence", async () => {
|
|
24620
|
+
const row = { id: "example", state: "approved", version: 2 };
|
|
24621
|
+
const service = new ${className}Service(undefined, {
|
|
24622
|
+
async approve(input) {
|
|
24623
|
+
expect(input).toEqual({ id: "example", expectedVersion: 1, reason: "reviewed" });
|
|
24624
|
+
return row;
|
|
24625
|
+
},
|
|
24626
|
+
});
|
|
24627
|
+
expect(await service.approve("example", { expectedVersion: 1, reason: "reviewed" })).toBe(row);
|
|
24628
|
+
});
|
|
24629
|
+
`,
|
|
24630
|
+
[`${name}.deployment.md`]: `# ${className} feature delivery checklist
|
|
24631
|
+
|
|
24632
|
+
- [ ] Bind ${className}Reader to a tenant-scoped, authenticated database read path.
|
|
24633
|
+
- [ ] Bind ${className}Writer to a transactional RPC or persistent Command adapter.
|
|
24634
|
+
- [ ] Register the \`${rpc}\` compiler and runtime RPC capability.
|
|
24635
|
+
- [ ] Verify RLS, permission denial, stale version, identical replay and conflicting replay.
|
|
24636
|
+
- [ ] Verify the refresh job uses the existing Workflow/Queue delivery path.
|
|
24637
|
+
- [ ] Run the generated client contract and authenticated E2E test.
|
|
24638
|
+
- [ ] Run \`supacloud app check --format json\`, package tests and the selected deployment receipt.
|
|
24639
|
+
|
|
24640
|
+
The scaffold does not create migrations, infer permissions, or provide an in-memory
|
|
24641
|
+
production fallback. Database migrations remain the database source of truth.
|
|
24642
|
+
The HTTP schemas describe the boundary, not a second database model. Bind their
|
|
24643
|
+
fields to the existing database-source contract checker before delivery.
|
|
24644
|
+
|
|
24645
|
+
The named RPC adapter must invoke the continuation in the same database transaction
|
|
24646
|
+
used by the request-scoped Writer, or own the complete command instead; never execute
|
|
24647
|
+
both paths. Lock/check the version and state, reauthorize replays, and commit the
|
|
24648
|
+
business write, receipt, audit and outbox together. Do not put a network side effect
|
|
24649
|
+
inside this transaction. The refresh Job is an explicit unimplemented integration
|
|
24650
|
+
point, not an automatically scheduled workflow.
|
|
24651
|
+
|
|
24652
|
+
Compile the routes with the existing client generator and use @supacloud/query for
|
|
24653
|
+
identity-scoped caching/invalidation. There is deliberately no second hand-maintained
|
|
24654
|
+
HTTP client. A committed approval followed by a failed refresh is still committed;
|
|
24655
|
+
recover its receipt instead of resending. Use the host's standard error translation
|
|
24656
|
+
and requestId propagation; the scaffold does not claim to implement those adapters.
|
|
24657
|
+
|
|
24658
|
+
## Required acceptance (not executed by generation)
|
|
24659
|
+
|
|
24660
|
+
\`\`\`gherkin
|
|
24661
|
+
Scenario: Authorization and tenant isolation
|
|
24662
|
+
Given two tenants and a revoked reviewer
|
|
24663
|
+
When list, detail, approval or receipt replay is requested
|
|
24664
|
+
Then unauthorized records and prior success receipts are not disclosed
|
|
24665
|
+
|
|
24666
|
+
Scenario: Atomic approval
|
|
24667
|
+
Given a draft and its authoritative version
|
|
24668
|
+
When an authorized reviewer approves and repeats the same request
|
|
24669
|
+
Then one transition, audit event and asynchronous intent commit
|
|
24670
|
+
And changed input or a stale version is rejected
|
|
24671
|
+
|
|
24672
|
+
Scenario: Unknown outcome
|
|
24673
|
+
Given a committed approval whose response was lost
|
|
24674
|
+
When the client recovers the original receipt
|
|
24675
|
+
Then it does not create a new approval or automatic compensation
|
|
24676
|
+
|
|
24677
|
+
Scenario: Delivery
|
|
24678
|
+
Given verified migrations, RLS and generated client contracts
|
|
24679
|
+
When the selected environment is deployed
|
|
24680
|
+
Then its receipt and authenticated list/detail/approval flow are read back
|
|
24681
|
+
\`\`\`
|
|
24682
|
+
`
|
|
24683
|
+
};
|
|
24684
|
+
}
|
|
24685
|
+
|
|
24390
24686
|
// src/shared/tools/app-local-dev.ts
|
|
24391
24687
|
import { spawn } from "node:child_process";
|
|
24392
24688
|
import { readFile as readFile2 } from "node:fs/promises";
|
|
@@ -24878,6 +25174,12 @@ function requireIdentifier(value, flag) {
|
|
|
24878
25174
|
}
|
|
24879
25175
|
return trimmed;
|
|
24880
25176
|
}
|
|
25177
|
+
function requireFeatureCapability(value, flag) {
|
|
25178
|
+
if (typeof value !== "string" || !/^[A-Za-z][A-Za-z0-9_.:-]{0,127}$/.test(value)) {
|
|
25179
|
+
throw new ScaffoldError("SCAFFOLD_OPTION_INVALID", `feature requires an explicit valid --${flag}; capabilities are not inferred`);
|
|
25180
|
+
}
|
|
25181
|
+
return value;
|
|
25182
|
+
}
|
|
24881
25183
|
function moduleScaffold(name) {
|
|
24882
25184
|
return `import { Module } from "@supacloud/app";
|
|
24883
25185
|
|
|
@@ -24896,6 +25198,9 @@ function commandScaffold(moduleName, name) {
|
|
|
24896
25198
|
@Command({
|
|
24897
25199
|
name: ${JSON.stringify(`${moduleName}.${camelName(name)}`)},
|
|
24898
25200
|
permission: ${JSON.stringify(`${moduleName}.${camelName(name)}`)},
|
|
25201
|
+
transaction: "required",
|
|
25202
|
+
audit: ${JSON.stringify(`${moduleName}.${camelName(name)}.completed`)},
|
|
25203
|
+
idempotency: "required",
|
|
24899
25204
|
})
|
|
24900
25205
|
export class ${pascalName(name)}Command {
|
|
24901
25206
|
execute(): void {
|
|
@@ -24950,8 +25255,8 @@ export const ${prefix}Response = t.Object({});
|
|
|
24950
25255
|
}
|
|
24951
25256
|
async function generateScaffold(args) {
|
|
24952
25257
|
const kind = args.kind;
|
|
24953
|
-
if (!kind || !["module", "command", "query", "controller", "job", "contract", "resource"].includes(kind)) {
|
|
24954
|
-
throw new ScaffoldError("SCAFFOLD_KIND_INVALID", "app generate requires --kind (module|command|query|controller|job|contract|resource)");
|
|
25258
|
+
if (!kind || !["module", "command", "query", "controller", "job", "contract", "resource", "feature"].includes(kind)) {
|
|
25259
|
+
throw new ScaffoldError("SCAFFOLD_KIND_INVALID", "app generate requires --kind (module|command|query|controller|job|contract|resource|feature)");
|
|
24955
25260
|
}
|
|
24956
25261
|
for (const flag of ["dry_run", "force"]) {
|
|
24957
25262
|
if (args[flag] !== undefined && typeof args[flag] !== "boolean") {
|
|
@@ -24962,24 +25267,31 @@ async function generateScaffold(args) {
|
|
|
24962
25267
|
const dir = args.dir ?? "src/features";
|
|
24963
25268
|
if (dir !== ".")
|
|
24964
25269
|
scaffoldPath(root, dir);
|
|
24965
|
-
if (args.register_in !== undefined && kind !== "resource" && kind !== "module") {
|
|
24966
|
-
throw new ScaffoldError("SCAFFOLD_OPTION_INVALID", "--register-in is only supported for a new module or
|
|
25270
|
+
if (args.register_in !== undefined && kind !== "resource" && kind !== "module" && kind !== "feature") {
|
|
25271
|
+
throw new ScaffoldError("SCAFFOLD_OPTION_INVALID", "--register-in is only supported for a new module, resource or feature");
|
|
25272
|
+
}
|
|
25273
|
+
if ((kind === "resource" || kind === "feature") && (args.force || args.module !== undefined)) {
|
|
25274
|
+
throw new ScaffoldError("SCAFFOLD_OPTION_INVALID", `${kind} creates its own module; use --name and optionally --register-in, never --force or --module`);
|
|
24967
25275
|
}
|
|
24968
|
-
if (kind
|
|
24969
|
-
throw new ScaffoldError("SCAFFOLD_OPTION_INVALID", "
|
|
25276
|
+
if (kind !== "feature" && (args.permission !== undefined || args.rpc !== undefined)) {
|
|
25277
|
+
throw new ScaffoldError("SCAFFOLD_OPTION_INVALID", "--permission and --rpc are only supported for feature generation");
|
|
24970
25278
|
}
|
|
25279
|
+
const featureGovernance = kind === "feature" ? {
|
|
25280
|
+
permission: requireFeatureCapability(args.permission, "permission"),
|
|
25281
|
+
rpc: requireFeatureCapability(args.rpc, "rpc")
|
|
25282
|
+
} : undefined;
|
|
24971
25283
|
const writes = [];
|
|
24972
25284
|
let modulePath;
|
|
24973
25285
|
let moduleSymbol;
|
|
24974
25286
|
let contractName;
|
|
24975
|
-
if (kind === "module" || kind === "resource") {
|
|
25287
|
+
if (kind === "module" || kind === "resource" || kind === "feature") {
|
|
24976
25288
|
const name = requireIdentifier(args.name, "name");
|
|
24977
|
-
modulePath = join13(dir, name, `${name}.module.ts`).replaceAll("\\", "/");
|
|
24978
|
-
moduleSymbol = `${pascalName(name)}Module`;
|
|
25289
|
+
modulePath = join13(dir, name, kind === "feature" ? `${name}.feature.ts` : `${name}.module.ts`).replaceAll("\\", "/");
|
|
25290
|
+
moduleSymbol = kind === "feature" ? `${pascalName(name)}Feature` : `${pascalName(name)}Module`;
|
|
24979
25291
|
if (kind === "module")
|
|
24980
25292
|
writes.push({ path: modulePath, content: moduleScaffold(name), overwrite: args.force === true });
|
|
24981
25293
|
else
|
|
24982
|
-
for (const [file, content] of Object.entries(resourceScaffold(name, pascalName(name)))) {
|
|
25294
|
+
for (const [file, content] of Object.entries(kind === "feature" ? featureScaffold(name, pascalName(name), featureGovernance) : resourceScaffold(name, pascalName(name)))) {
|
|
24983
25295
|
writes.push({ path: join13(dir, name, file).replaceAll("\\", "/"), content });
|
|
24984
25296
|
}
|
|
24985
25297
|
} else {
|
|
@@ -25041,6 +25353,8 @@ async function generateScaffold(args) {
|
|
|
25041
25353
|
lines.push("No files written. Omit --dry-run to apply this generation.");
|
|
25042
25354
|
if (kind === "resource")
|
|
25043
25355
|
lines.push("Service and controller are registered in the generated module. No root module is guessed.", "Use --register-in <module.ts> to add an explicit parent import. Implement the read port before exposing the resource.", "Run app compile, app check --format json and your tests. No writes, permissions or persistence are inferred.");
|
|
25356
|
+
if (kind === "feature")
|
|
25357
|
+
lines.push(`Approval feature scaffold ${args.dry_run ? "planned" : "created"}; no database, authorization or job adapter is fabricated.`, "Bind the explicit RPC in compiler capabilities and runtime governance. Review the generated delivery checklist.", "Generate the client through app compile; no handwritten transport or database schema is created.");
|
|
25044
25358
|
if (contractName) {
|
|
25045
25359
|
const prefix = pascalName(contractName);
|
|
25046
25360
|
lines.push("", "Next, bind the contract to a route so the compiler can check route/schema drift:", ` import { ${prefix}Body, ${prefix}Response } from "./contracts/${contractName}.contract";`, ` @Post("/", { body: ${prefix}Body, responses: { 200: ${prefix}Response } })`, "Fill in the schema fields and reuse them in command decoders; never widen them to unknown.");
|
|
@@ -26330,15 +26644,17 @@ function registerAppTools(server, options = {}) {
|
|
|
26330
26644
|
"promote-status",
|
|
26331
26645
|
"promote-reconcile"
|
|
26332
26646
|
]), "App action; promote obtains and verifies its plan before one mutation request"),
|
|
26333
|
-
kind: optional(stringEnum(["module", "command", "query", "controller", "job", "contract", "resource"]), "[generate] Scaffold kind"),
|
|
26647
|
+
kind: optional(stringEnum(["module", "command", "query", "controller", "job", "contract", "resource", "feature"]), "[generate] Scaffold kind"),
|
|
26334
26648
|
template: optional(stringEnum(["minimal", "http", "command", "edge"]), "[init] Minimal application by default; explicit http/command/edge recipes"),
|
|
26335
26649
|
name: optional(String2(), "[init/generate] Project or object name"),
|
|
26336
26650
|
module: optional(String2(), "[generate] Target feature module (required for command/query/controller)"),
|
|
26651
|
+
permission: optional(String2(), "[generate feature] Explicit approval permission; not inferred from name"),
|
|
26652
|
+
rpc: optional(String2(), "[generate feature] Explicit durable RPC capability; must be registered by the host"),
|
|
26337
26653
|
dir: optional(String2(), "[generate] Feature root directory (default: src/features)"),
|
|
26338
|
-
force: optional(Boolean2(), "[generate] Overwrite a single regular file; resource/controller never overwrite"),
|
|
26654
|
+
force: optional(Boolean2(), "[generate] Overwrite a single regular file; resource/feature/controller never overwrite"),
|
|
26339
26655
|
dry_run: optional(Boolean2(), "[generate] Validate and preview the complete change set without writing"),
|
|
26340
26656
|
"dry-run": optional(Boolean2(), "[generate] CLI alias of dry_run"),
|
|
26341
|
-
register_in: optional(String2(), "[generate] Existing parent module file for module/resource; uses the compiler AST editor"),
|
|
26657
|
+
register_in: optional(String2(), "[generate] Existing parent module file for module/resource/feature; uses the compiler AST editor"),
|
|
26342
26658
|
"register-in": optional(String2(), "[generate] CLI alias of register_in"),
|
|
26343
26659
|
root: optional(String2(), "[dev/plan/build/release/compile/check] Project directory containing supacloud.config.ts (default: current directory)"),
|
|
26344
26660
|
include: optional(String2(), "[dev/compile/check] Comma-separated glob patterns for source files"),
|
|
@@ -26360,14 +26676,16 @@ function registerAppTools(server, options = {}) {
|
|
|
26360
26676
|
var APP_ALIAS_ACTIONS = ["generate", "compile", "check", "graph", "explain", "context", "doctor", "fix"];
|
|
26361
26677
|
function registerAppAliases(server) {
|
|
26362
26678
|
const schema = {
|
|
26363
|
-
kind: optional(stringEnum(["module", "command", "query", "controller", "job", "contract", "resource"]), "[generate] Scaffold kind"),
|
|
26679
|
+
kind: optional(stringEnum(["module", "command", "query", "controller", "job", "contract", "resource", "feature"]), "[generate] Scaffold kind"),
|
|
26364
26680
|
name: optional(String2(), "[generate] Object name"),
|
|
26365
26681
|
module: optional(String2(), "[generate] Target feature module"),
|
|
26682
|
+
permission: optional(String2(), "[generate feature] Explicit approval permission; not inferred from name"),
|
|
26683
|
+
rpc: optional(String2(), "[generate feature] Explicit durable RPC capability; must be registered by the host"),
|
|
26366
26684
|
dir: optional(String2(), "[generate] Feature root directory (default: src/features)"),
|
|
26367
|
-
force: optional(Boolean2(), "[generate] Overwrite a single regular file; resource/controller never overwrite"),
|
|
26685
|
+
force: optional(Boolean2(), "[generate] Overwrite a single regular file; resource/feature/controller never overwrite"),
|
|
26368
26686
|
dry_run: optional(Boolean2(), "[generate] Validate and preview the complete change set without writing"),
|
|
26369
26687
|
"dry-run": optional(Boolean2(), "[generate] CLI alias of dry_run"),
|
|
26370
|
-
register_in: optional(String2(), "[generate] Existing parent module file for module/resource; uses the compiler AST editor"),
|
|
26688
|
+
register_in: optional(String2(), "[generate] Existing parent module file for module/resource/feature; uses the compiler AST editor"),
|
|
26371
26689
|
"register-in": optional(String2(), "[generate] CLI alias of register_in"),
|
|
26372
26690
|
root: optional(String2(), "Project directory containing supacloud.config.ts (default: current directory)"),
|
|
26373
26691
|
include: optional(String2(), "[compile/check] Comma-separated glob patterns for source files"),
|
|
@@ -29537,7 +29855,7 @@ function registerDeveloperExperienceTools(server, options = {}) {
|
|
|
29537
29855
|
// package.json
|
|
29538
29856
|
var package_default = {
|
|
29539
29857
|
name: "@supacloud/cli",
|
|
29540
|
-
version: "0.
|
|
29858
|
+
version: "0.97.0",
|
|
29541
29859
|
description: "Project-scoped CLI for SupaCloud users",
|
|
29542
29860
|
type: "module",
|
|
29543
29861
|
main: "./dist/index.js",
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An editable vertical feature slice. The generated ports are deliberately
|
|
3
|
+
* fail-closed: database access, authorization and durable command adapters
|
|
4
|
+
* remain application-owned and cannot be fabricated by the scaffold.
|
|
5
|
+
*/
|
|
6
|
+
export declare function featureScaffold(name: string, className: string, governance: {
|
|
7
|
+
permission: string;
|
|
8
|
+
rpc: string;
|
|
9
|
+
}): Record<string, string>;
|
|
@@ -65,10 +65,12 @@ export interface AppToolArguments {
|
|
|
65
65
|
ttl_seconds?: number;
|
|
66
66
|
wait?: boolean;
|
|
67
67
|
timeout_seconds?: number;
|
|
68
|
-
kind?: "module" | "command" | "query" | "controller" | "job" | "contract" | "resource";
|
|
68
|
+
kind?: "module" | "command" | "query" | "controller" | "job" | "contract" | "resource" | "feature";
|
|
69
69
|
template?: "minimal" | "http" | "command" | "edge";
|
|
70
70
|
name?: string;
|
|
71
71
|
module?: string;
|
|
72
|
+
permission?: string;
|
|
73
|
+
rpc?: string;
|
|
72
74
|
dir?: string;
|
|
73
75
|
force?: boolean;
|
|
74
76
|
dry_run?: boolean;
|