@clawos-dev/clawd 0.2.519 → 0.2.521
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/app-builder-plugin/mcp-server.cjs +65 -19
- package/dist/cli.cjs +1 -1
- package/dist/deploy-kit/scripts/ensure-credentials.spec.ts +139 -18
- package/dist/deploy-kit/scripts/new-extension.sh +6 -2
- package/dist/deploy-kit/scripts/publish.sh +32 -9
- package/dist/deploy-kit/scripts/publish.spec.ts +72 -0
- package/dist/deploy-kit/scripts/remove-extension.sh +36 -9
- package/dist/persona-defaults/persona-app-builder/.claude/skills/app-builder-projects/SKILL.md +20 -4
- package/dist/persona-defaults/persona-app-builder/.claude/skills/app-builder-projects/scripts/collect-files.mjs +2 -2
- package/dist/persona-defaults/persona-app-builder/.claude/skills/app-builder-projects/scripts/list-projects.mjs +4 -1
- package/dist/persona-defaults/persona-app-builder/.claude/skills/app-builder-projects/scripts/render-sql.mjs +64 -0
- package/dist/persona-defaults/persona-app-builder/CLAUDE.md +19 -14
- package/dist/persona-defaults/persona-app-builder/extension-kit/README.md +1 -1
- package/dist/persona-defaults/persona-app-builder/extension-kit/contract/s.yaml.tmpl +8 -3
- package/dist/persona-defaults/persona-app-builder/extension-kit/examples/nestjs-react/ext.conf +3 -1
- package/dist/persona-defaults/persona-app-builder/extension-kit/examples/nestjs-react/server/.env.example +4 -0
- package/dist/persona-defaults/persona-app-builder/extension-kit/examples/nestjs-react/server/.fcignore +1 -0
- package/dist/persona-defaults/persona-app-builder/extension-kit/examples/nestjs-react/server/db/schema.sql +15 -0
- package/dist/persona-defaults/persona-app-builder/extension-kit/examples/nestjs-react/server/src/main.ts +3 -1
- package/dist/persona-defaults/persona-app-builder/extension-kit/examples/nestjs-react/server/src/table-name.ts +19 -0
- package/dist/persona-defaults/persona-dataclaw-builder/.claude/skills/app-builder-projects/SKILL.md +20 -4
- package/dist/persona-defaults/persona-dataclaw-builder/.claude/skills/app-builder-projects/scripts/collect-files.mjs +2 -2
- package/dist/persona-defaults/persona-dataclaw-builder/.claude/skills/app-builder-projects/scripts/list-projects.mjs +4 -1
- package/dist/persona-defaults/persona-dataclaw-builder/.claude/skills/app-builder-projects/scripts/render-sql.mjs +64 -0
- package/dist/persona-defaults/persona-dataclaw-builder/CLAUDE.md +4 -2
- package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/README.md +1 -1
- package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/contract/s.yaml.tmpl +15 -6
- package/package.json +1 -1
|
@@ -6890,6 +6890,7 @@ var require_dist = __commonJS({
|
|
|
6890
6890
|
// src/app-builder-plugin/mcp-server.ts
|
|
6891
6891
|
var mcp_server_exports = {};
|
|
6892
6892
|
__export(mcp_server_exports, {
|
|
6893
|
+
PUBLISH_INPUT_SCHEMA: () => PUBLISH_INPUT_SCHEMA,
|
|
6893
6894
|
handleCreateProject: () => handleCreateProject,
|
|
6894
6895
|
handlePublish: () => handlePublish,
|
|
6895
6896
|
handleRemoveProject: () => handleRemoveProject,
|
|
@@ -21127,6 +21128,14 @@ async function exists(p) {
|
|
|
21127
21128
|
}
|
|
21128
21129
|
}
|
|
21129
21130
|
var PROJECT_NAME_RE = /^[a-z][a-z0-9-]{0,39}$/;
|
|
21131
|
+
var DEPLOY_ENVS = ["integration", "prod"];
|
|
21132
|
+
var DeploymentSchema = external_exports.object({
|
|
21133
|
+
/** 公网地址。 */
|
|
21134
|
+
url: external_exports.string().url(),
|
|
21135
|
+
publishedAt: external_exports.string().datetime().optional(),
|
|
21136
|
+
/** 发布那一刻对应的 commit:本地 git 仓库取 HEAD,否则取 pushedCommit;两样都没有时缺席。 */
|
|
21137
|
+
publishedCommit: external_exports.string().min(1).optional()
|
|
21138
|
+
});
|
|
21130
21139
|
var AppProjectMetadataSchema = external_exports.object({
|
|
21131
21140
|
name: external_exports.string().regex(PROJECT_NAME_RE, {
|
|
21132
21141
|
message: "kebab-case, \u5C0F\u5199\u5B57\u6BCD\u5F00\u5934\uFF0C\u53EA\u5141\u8BB8\u5C0F\u5199\u5B57\u6BCD / \u6570\u5B57 / -\uFF0C\u6700\u957F 40 \u5B57\u7B26"
|
|
@@ -21138,12 +21147,32 @@ var AppProjectMetadataSchema = external_exports.object({
|
|
|
21138
21147
|
pushedCommit: external_exports.string().min(1).optional(),
|
|
21139
21148
|
/** push 那一刻 collect-files.mjs 算的文件树哈希(record-push.mjs 记);publish 拿它判「有没有没推的改动」。 */
|
|
21140
21149
|
pushedTreeHash: external_exports.string().min(1).optional(),
|
|
21141
|
-
/**
|
|
21150
|
+
/** 每环境一份发布记录。prod 门拿 `integration.publishedCommit` 跟本次要发的 head 比。 */
|
|
21151
|
+
deployments: external_exports.object({
|
|
21152
|
+
integration: DeploymentSchema.optional(),
|
|
21153
|
+
prod: DeploymentSchema.optional()
|
|
21154
|
+
}).optional()
|
|
21155
|
+
});
|
|
21156
|
+
var StoredMetadataSchema = AppProjectMetadataSchema.extend({
|
|
21142
21157
|
prodUrl: external_exports.string().url().optional(),
|
|
21143
21158
|
publishedAt: external_exports.string().datetime().optional(),
|
|
21144
|
-
/** 发布那一刻对应的 commit:本地 git 仓库取 HEAD,否则取 pushedCommit;两样都没有时缺席。 */
|
|
21145
21159
|
publishedCommit: external_exports.string().min(1).optional()
|
|
21146
21160
|
});
|
|
21161
|
+
function foldLegacy(raw) {
|
|
21162
|
+
const { prodUrl, publishedAt, publishedCommit, ...rest } = raw;
|
|
21163
|
+
if (!prodUrl || rest.deployments?.prod) return rest;
|
|
21164
|
+
return {
|
|
21165
|
+
...rest,
|
|
21166
|
+
deployments: {
|
|
21167
|
+
...rest.deployments,
|
|
21168
|
+
prod: {
|
|
21169
|
+
url: prodUrl,
|
|
21170
|
+
...publishedAt ? { publishedAt } : {},
|
|
21171
|
+
...publishedCommit ? { publishedCommit } : {}
|
|
21172
|
+
}
|
|
21173
|
+
}
|
|
21174
|
+
};
|
|
21175
|
+
}
|
|
21147
21176
|
var ProjectStore = class {
|
|
21148
21177
|
/** @param root projects 父目录,例如 `~/.clawd/users/<userId>/` */
|
|
21149
21178
|
constructor(root) {
|
|
@@ -21180,8 +21209,8 @@ var ProjectStore = class {
|
|
|
21180
21209
|
async readMeta(name) {
|
|
21181
21210
|
try {
|
|
21182
21211
|
const raw = await import_node_fs.promises.readFile(this.metaPath(name), "utf8");
|
|
21183
|
-
const parsed =
|
|
21184
|
-
return parsed.success ? parsed.data : void 0;
|
|
21212
|
+
const parsed = StoredMetadataSchema.safeParse(JSON.parse(raw));
|
|
21213
|
+
return parsed.success ? foldLegacy(parsed.data) : void 0;
|
|
21185
21214
|
} catch {
|
|
21186
21215
|
return void 0;
|
|
21187
21216
|
}
|
|
@@ -21239,8 +21268,11 @@ var ProjectStore = class {
|
|
|
21239
21268
|
async delete(name) {
|
|
21240
21269
|
await import_node_fs.promises.rm(this.projectDir(name), { recursive: true, force: true });
|
|
21241
21270
|
}
|
|
21242
|
-
|
|
21243
|
-
|
|
21271
|
+
/** 记一个环境的成功发布;另一个环境那份原样保留。 */
|
|
21272
|
+
async setPublished(name, env, fields) {
|
|
21273
|
+
const current = await this.readMeta(name);
|
|
21274
|
+
if (!current) throw new Error(`project "${name}" not found / \u4E0D\u5B58\u5728`);
|
|
21275
|
+
return this.patch(name, { deployments: { ...current.deployments, [env]: fields } });
|
|
21244
21276
|
}
|
|
21245
21277
|
/** 读全量 → 改几个字段 → 校验 → 整份写回。其他字段保持原样。 */
|
|
21246
21278
|
async patch(name, fields) {
|
|
@@ -24957,13 +24989,21 @@ async function runPublish(args) {
|
|
|
24957
24989
|
}
|
|
24958
24990
|
head = meta.pushedCommit;
|
|
24959
24991
|
}
|
|
24992
|
+
if (args.env === "prod" && (tree.isRepo || meta.repoUrl)) {
|
|
24993
|
+
const verified = meta.deployments?.integration?.publishedCommit;
|
|
24994
|
+
if (!head || verified !== head) {
|
|
24995
|
+
throw new Error(
|
|
24996
|
+
`\u8FD9\u4E2A commit${head ? `\uFF08${head.slice(0, 7)}\uFF09` : ""}\u8FD8\u6CA1\u53D1\u8FC7 integration\uFF1A\u5148 publish({ name: "${name}", env: "integration" })\uFF0C\u8001\u677F\u5728\u6D4B\u8BD5\u5730\u5740\u786E\u8BA4\u6CA1\u95EE\u9898\u540E\u518D\u53D1 prod`
|
|
24997
|
+
);
|
|
24998
|
+
}
|
|
24999
|
+
}
|
|
24960
25000
|
const logPath = (0, import_node_path5.join)(dir, ".publish.log");
|
|
24961
25001
|
const log2 = (0, import_node_fs3.createWriteStream)(logPath, { flags: "w" });
|
|
24962
25002
|
log2.on("error", () => {
|
|
24963
25003
|
});
|
|
24964
|
-
logger2?.info("app-builder.publish.start", { name, scriptPath: args.scriptPath, head });
|
|
25004
|
+
logger2?.info("app-builder.publish.start", { name, env: args.env, scriptPath: args.scriptPath, head });
|
|
24965
25005
|
return await new Promise((resolve, reject) => {
|
|
24966
|
-
const child = spawnFn("bash", [args.scriptPath, dir, args.personaRoot], {
|
|
25006
|
+
const child = spawnFn("bash", [args.scriptPath, dir, args.personaRoot, args.env], {
|
|
24967
25007
|
cwd: dir,
|
|
24968
25008
|
// 凭据就在本进程 env 里(宿主交进来的),照单全给脚本
|
|
24969
25009
|
env: process.env,
|
|
@@ -24973,12 +25013,12 @@ async function runPublish(args) {
|
|
|
24973
25013
|
let stderr = "";
|
|
24974
25014
|
let partial2 = "";
|
|
24975
25015
|
let stage = "unknown";
|
|
24976
|
-
let
|
|
25016
|
+
let url = null;
|
|
24977
25017
|
let timedOut = false;
|
|
24978
25018
|
const feed = (line) => {
|
|
24979
25019
|
const p = parsePublishLine(line);
|
|
24980
25020
|
if (p?.kind === "stage") stage = p.stage;
|
|
24981
|
-
else if (p?.kind === "prod-url")
|
|
25021
|
+
else if (p?.kind === "prod-url") url = p.url;
|
|
24982
25022
|
};
|
|
24983
25023
|
child.stdout?.on("data", (d) => {
|
|
24984
25024
|
const s = d.toString("utf8");
|
|
@@ -25013,11 +25053,11 @@ async function runPublish(args) {
|
|
|
25013
25053
|
if (partial2) feed(partial2);
|
|
25014
25054
|
log2.end();
|
|
25015
25055
|
void (async () => {
|
|
25016
|
-
if (code === 0 &&
|
|
25056
|
+
if (code === 0 && url) {
|
|
25017
25057
|
const publishedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
25018
25058
|
try {
|
|
25019
|
-
await store.setPublished(name, {
|
|
25020
|
-
|
|
25059
|
+
await store.setPublished(name, args.env, {
|
|
25060
|
+
url,
|
|
25021
25061
|
publishedAt,
|
|
25022
25062
|
...head ? { publishedCommit: head } : {}
|
|
25023
25063
|
});
|
|
@@ -25025,8 +25065,8 @@ async function runPublish(args) {
|
|
|
25025
25065
|
reject(new Error(`\u53D1\u5E03\u6210\u529F\u4F46\u5199\u56DE\u5143\u6570\u636E\u5931\u8D25\uFF1A${errMsg(err)}`));
|
|
25026
25066
|
return;
|
|
25027
25067
|
}
|
|
25028
|
-
logger2?.info("app-builder.publish.done", { name,
|
|
25029
|
-
resolve({ ok: true,
|
|
25068
|
+
logger2?.info("app-builder.publish.done", { name, env: args.env, url, head });
|
|
25069
|
+
resolve({ ok: true, env: args.env, url, commit: head, publishedAt, logPath });
|
|
25030
25070
|
return;
|
|
25031
25071
|
}
|
|
25032
25072
|
const errorSummary = code === 0 ? "\u811A\u672C\u9000\u51FA 0 \u4F46\u6CA1\u6709\u8F93\u51FA ::prod-url:: \u6807\u8BB0\uFF0C\u62FF\u4E0D\u5230\u516C\u7F51\u5730\u5740" : tailStderrLines(stderr, 20) || tailStderrLines(stdout, 20) || `exit ${code ?? signal}`;
|
|
@@ -25136,6 +25176,10 @@ async function handleCreateProject(input, ctx) {
|
|
|
25136
25176
|
return fail(`createProject failed: ${errMsg2(err)}`);
|
|
25137
25177
|
}
|
|
25138
25178
|
}
|
|
25179
|
+
var PUBLISH_INPUT_SCHEMA = {
|
|
25180
|
+
name: external_exports.string().min(1).describe("project name"),
|
|
25181
|
+
env: external_exports.enum(DEPLOY_ENVS).describe("integration = test deployment (<name>-<slug>-int) for the user to check first; prod = the real one, only accepted after integration published the same commit")
|
|
25182
|
+
};
|
|
25139
25183
|
async function handlePublish(input, ctx) {
|
|
25140
25184
|
const miss = missingEnv(ctx.env, ["root", "publishScript", "personaRoot"]);
|
|
25141
25185
|
if (miss) return fail(`publish: ${miss}`);
|
|
@@ -25144,6 +25188,7 @@ async function handlePublish(input, ctx) {
|
|
|
25144
25188
|
const r = await runPublish({
|
|
25145
25189
|
store: new ProjectStore(env.root),
|
|
25146
25190
|
name: input.name,
|
|
25191
|
+
env: input.env,
|
|
25147
25192
|
scriptPath: env.publishScript,
|
|
25148
25193
|
personaRoot: env.personaRoot,
|
|
25149
25194
|
...ctx.exec ? { exec: ctx.exec } : {},
|
|
@@ -25151,7 +25196,7 @@ async function handlePublish(input, ctx) {
|
|
|
25151
25196
|
logger
|
|
25152
25197
|
});
|
|
25153
25198
|
if (r.ok) {
|
|
25154
|
-
return ok({ status: "published", name: input.name,
|
|
25199
|
+
return ok({ status: "published", name: input.name, env: r.env, url: r.url, commit: r.commit, publishedAt: r.publishedAt });
|
|
25155
25200
|
}
|
|
25156
25201
|
return fail(`\u53D1\u5E03\u5931\u8D25\uFF1A[${r.stage}] \u9636\u6BB5
|
|
25157
25202
|
${r.errorSummary}
|
|
@@ -25201,9 +25246,9 @@ function registerTools(server, ctx) {
|
|
|
25201
25246
|
server.registerTool(
|
|
25202
25247
|
"publish",
|
|
25203
25248
|
{
|
|
25204
|
-
title: "Publish the project to
|
|
25205
|
-
description:
|
|
25206
|
-
inputSchema:
|
|
25249
|
+
title: "Publish the project to one environment (integration first, then prod)",
|
|
25250
|
+
description: `Run the publish pipeline (build \u2192 deploy to Aliyun FC \u2192 verify) for ONE environment and wait for it to finish. Two-step release: env="integration" deploys <name>-<slug>-int (test URL, give it to the user to check); env="prod" deploys <name>-<slug> (the real URL) and is only accepted when integration already published the same commit \u2014 otherwise it refuses and tells you to publish integration first. Returns { env, url, commit, publishedAt } on success; on failure returns the failed stage, the error tail and the path of the full log. Also refuses to run if the project has changes that were not pushed: for a local git repo that means a dirty work tree; for a github-MCP project it recomputes the file tree hash with collect-files.mjs and compares it with what record-push.mjs recorded. Push first, then publish. Never run publish.sh yourself: the credentials only exist in this tool's process.`,
|
|
25251
|
+
inputSchema: PUBLISH_INPUT_SCHEMA
|
|
25207
25252
|
},
|
|
25208
25253
|
async (input) => handlePublish(input, ctx)
|
|
25209
25254
|
);
|
|
@@ -25234,6 +25279,7 @@ if (isMainModule) {
|
|
|
25234
25279
|
}
|
|
25235
25280
|
// Annotate the CommonJS export names for ESM import in node:
|
|
25236
25281
|
0 && (module.exports = {
|
|
25282
|
+
PUBLISH_INPUT_SCHEMA,
|
|
25237
25283
|
handleCreateProject,
|
|
25238
25284
|
handlePublish,
|
|
25239
25285
|
handleRemoveProject,
|
package/dist/cli.cjs
CHANGED
|
@@ -70380,7 +70380,7 @@ function computeMethodAccess(args) {
|
|
|
70380
70380
|
}
|
|
70381
70381
|
|
|
70382
70382
|
// src/version.ts
|
|
70383
|
-
var version = "0.2.
|
|
70383
|
+
var version = "0.2.521".length > 0 ? "0.2.521" : "dev";
|
|
70384
70384
|
|
|
70385
70385
|
// src/cli-probe/probe.ts
|
|
70386
70386
|
var fs77 = __toESM(require("fs"), 1);
|
|
@@ -249,14 +249,11 @@ printf 'id=%s\\n' "$ALIBABA_CLOUD_ACCESS_KEY_ID"`,
|
|
|
249
249
|
})
|
|
250
250
|
|
|
251
251
|
describe('publish.sh', () => {
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
writeSShim(box.bin, log)
|
|
252
|
+
/** 最小 persona kit + 项目目录;模板只留跟本文件断言相关的占位符。 */
|
|
253
|
+
function publishFixture(): { ext: string; persona: string } {
|
|
255
254
|
// 严格 shim 的自定义域名查询回空 → publish.sh 在 verify 段 exit 1。deploy 之后的事,
|
|
256
|
-
//
|
|
257
|
-
|
|
258
|
-
writeAliyunShim(box.bin, '5566778899001122', aliyunLog)
|
|
259
|
-
|
|
255
|
+
// 不影响本用例要断言的凭证 / 渲染行为,省掉 curl shim。
|
|
256
|
+
writeAliyunShim(box.bin, '5566778899001122', join(box.root, 'aliyun-argv.log'))
|
|
260
257
|
const ext = join(box.root, 'proj')
|
|
261
258
|
const persona = join(box.root, 'persona')
|
|
262
259
|
mkdirSync(join(ext, 'server'), { recursive: true })
|
|
@@ -265,39 +262,163 @@ describe('publish.sh', () => {
|
|
|
265
262
|
writeFileSync(join(box.kit, 'contract', 'bootstrap'), '#!/usr/bin/env bash\n')
|
|
266
263
|
writeFileSync(join(ext, 'ext.conf'), 'APP_NAME=demo\nSLUG=ab12\nCODE_DIR=./server\n')
|
|
267
264
|
writeFileSync(join(persona, 'extension-kit', 'config.env'), 'REGION=cn-hangzhou\n')
|
|
268
|
-
writeFileSync(
|
|
269
|
-
|
|
265
|
+
writeFileSync(
|
|
266
|
+
join(persona, 'extension-kit', 'contract', 's.yaml.tmpl'),
|
|
267
|
+
'name: __DEPLOY_NAME__-app\naccess: clawd-fc\nenv: __APP_ENV__\nprefix: __TABLE_PREFIX__\n',
|
|
268
|
+
)
|
|
269
|
+
return { ext, persona }
|
|
270
|
+
}
|
|
271
|
+
function runPublish(ext: string, persona: string, env: string): void {
|
|
270
272
|
expect(() =>
|
|
271
|
-
execFileSync('bash', [join(box.kit, 'scripts', 'publish.sh'), ext, persona], {
|
|
273
|
+
execFileSync('bash', [join(box.kit, 'scripts', 'publish.sh'), ext, persona, env], {
|
|
272
274
|
env: { ...process.env, HOME: box.home, PATH: `${box.bin}:${process.env.PATH}` },
|
|
273
275
|
encoding: 'utf8',
|
|
274
276
|
stdio: 'pipe',
|
|
275
277
|
}),
|
|
276
278
|
).toThrow() // verify 段无域名 → exit 1,deploy 已经跑过了
|
|
279
|
+
}
|
|
277
280
|
|
|
278
|
-
|
|
281
|
+
it('用 clawd-fc profile 调 s deploy,不碰用户全局 ~/.s', () => {
|
|
282
|
+
const log = join(box.root, 's-argv.log')
|
|
283
|
+
writeSShim(box.bin, log)
|
|
284
|
+
const { ext, persona } = publishFixture()
|
|
285
|
+
runPublish(ext, persona, 'integration')
|
|
286
|
+
expect(readFileSync(log, 'utf8')).toContain('deploy -y -t s.int.yaml --access clawd-fc')
|
|
279
287
|
expect(userAccessYaml()).toBe(USER_ACCESS_YAML)
|
|
280
288
|
})
|
|
281
|
-
})
|
|
282
289
|
|
|
283
|
-
|
|
284
|
-
|
|
290
|
+
// 双环境(spec 2026-09-15-app-builder-integration-env-design §3.2):同一个项目两个 env 各渲各的
|
|
291
|
+
// s 文件、各自的 DEPLOY_NAME / TABLE_PREFIX,prod 那份跟双环境之前的值一字不差(老地址不动)。
|
|
292
|
+
it('integration → s.int.yaml(-int / _int_),prod → s.prod.yaml(老值),两份并存互不覆盖', () => {
|
|
293
|
+
const log = join(box.root, 's-argv.log')
|
|
294
|
+
writeSShim(box.bin, log)
|
|
295
|
+
const { ext, persona } = publishFixture()
|
|
296
|
+
runPublish(ext, persona, 'integration')
|
|
297
|
+
runPublish(ext, persona, 'prod')
|
|
298
|
+
expect(readFileSync(join(ext, 's.int.yaml'), 'utf8')).toBe('name: demo-ab12-int-app\naccess: clawd-fc\nenv: integration\nprefix: demo_ab12_int_\n')
|
|
299
|
+
expect(readFileSync(join(ext, 's.prod.yaml'), 'utf8')).toBe('name: demo-ab12-app\naccess: clawd-fc\nenv: prod\nprefix: demo_ab12_\n')
|
|
300
|
+
expect(existsSync(join(ext, 's.yaml'))).toBe(false)
|
|
301
|
+
const argv = readFileSync(log, 'utf8')
|
|
302
|
+
expect(argv).toContain('deploy -y -t s.int.yaml --access clawd-fc')
|
|
303
|
+
expect(argv).toContain('deploy -y -t s.prod.yaml --access clawd-fc')
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
// 存量项目的 .gitignore 是 scaffold 时拷的一次性文件,只有 `s.yaml`:s.int.yaml 渲下去会被当成源码推上 GitHub
|
|
307
|
+
//(里面有 supabase key)。publish.sh 像 bake SLUG 那样幂等地补一行,模板项目已有的不重复。
|
|
308
|
+
it('项目 .gitignore 没有 s.*.yaml 就补上(幂等);没有 .gitignore 也建', () => {
|
|
309
|
+
writeSShim(box.bin, join(box.root, 's-argv.log'))
|
|
310
|
+
const { ext, persona } = publishFixture()
|
|
311
|
+
writeFileSync(join(ext, '.gitignore'), 'node_modules/\ns.yaml\n')
|
|
312
|
+
runPublish(ext, persona, 'integration')
|
|
313
|
+
expect(readFileSync(join(ext, '.gitignore'), 'utf8')).toBe('node_modules/\ns.yaml\ns.*.yaml\n')
|
|
314
|
+
runPublish(ext, persona, 'prod')
|
|
315
|
+
expect(readFileSync(join(ext, '.gitignore'), 'utf8')).toBe('node_modules/\ns.yaml\ns.*.yaml\n')
|
|
316
|
+
rmSync(join(ext, '.gitignore'))
|
|
317
|
+
runPublish(ext, persona, 'integration')
|
|
318
|
+
expect(readFileSync(join(ext, '.gitignore'), 'utf8')).toBe('s.*.yaml\n')
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
it('缺 env / 非法 env:渲染和 deploy 都不跑', () => {
|
|
285
322
|
const log = join(box.root, 's-argv.log')
|
|
286
323
|
writeSShim(box.bin, log)
|
|
324
|
+
const { ext, persona } = publishFixture()
|
|
325
|
+
for (const args of [[ext, persona], [ext, persona, 'staging']]) {
|
|
326
|
+
expect(() =>
|
|
327
|
+
execFileSync('bash', [join(box.kit, 'scripts', 'publish.sh'), ...args], {
|
|
328
|
+
env: { ...process.env, HOME: box.home, PATH: `${box.bin}:${process.env.PATH}` },
|
|
329
|
+
encoding: 'utf8',
|
|
330
|
+
stdio: 'pipe',
|
|
331
|
+
}),
|
|
332
|
+
).toThrow(/integration\|prod/)
|
|
333
|
+
}
|
|
334
|
+
expect(existsSync(log)).toBe(false)
|
|
335
|
+
expect(existsSync(join(ext, 's.int.yaml'))).toBe(false)
|
|
336
|
+
})
|
|
337
|
+
})
|
|
287
338
|
|
|
339
|
+
describe('remove-extension.sh', () => {
|
|
340
|
+
function removeFixture(opts: { sFiles: string[]; tables?: string; names?: Record<string, string> }): { ext: string; persona: string } {
|
|
288
341
|
const ext = join(box.root, 'proj')
|
|
289
342
|
const persona = join(box.root, 'persona')
|
|
290
343
|
mkdirSync(ext, { recursive: true })
|
|
291
344
|
mkdirSync(join(persona, 'extension-kit'), { recursive: true })
|
|
292
|
-
writeFileSync(join(ext, 'ext.conf'),
|
|
345
|
+
writeFileSync(join(ext, 'ext.conf'), `APP_NAME=demo\nSLUG=ab12\n${opts.tables !== undefined ? `SUPABASE_TABLES="${opts.tables}"\n` : ''}`)
|
|
293
346
|
writeFileSync(join(persona, 'extension-kit', 'config.env'), 'REGION=cn-hangzhou\n')
|
|
294
|
-
|
|
295
|
-
|
|
347
|
+
for (const f of opts.sFiles) writeFileSync(join(ext, f), `name: ${opts.names?.[f] ?? 'x'}\n`)
|
|
348
|
+
return { ext, persona }
|
|
349
|
+
}
|
|
350
|
+
function runRemove(ext: string, persona: string): string {
|
|
351
|
+
return execFileSync('bash', [join(box.kit, 'scripts', 'remove-extension.sh'), ext, persona], {
|
|
296
352
|
env: { ...process.env, HOME: box.home, PATH: `${box.bin}:${process.env.PATH}` },
|
|
297
353
|
encoding: 'utf8',
|
|
298
354
|
})
|
|
355
|
+
}
|
|
299
356
|
|
|
300
|
-
|
|
357
|
+
it('用 clawd-fc profile 调 s remove,不碰用户全局 ~/.s', () => {
|
|
358
|
+
const log = join(box.root, 's-argv.log')
|
|
359
|
+
writeSShim(box.bin, log)
|
|
360
|
+
const { ext, persona } = removeFixture({ sFiles: ['s.int.yaml'] })
|
|
361
|
+
runRemove(ext, persona)
|
|
362
|
+
expect(readFileSync(log, 'utf8')).toContain('remove -y -t s.int.yaml --access clawd-fc')
|
|
301
363
|
expect(userAccessYaml()).toBe(USER_ACCESS_YAML)
|
|
302
364
|
})
|
|
365
|
+
|
|
366
|
+
// 双环境:两个 env 各 s remove 一次(哪份 s 文件在删哪份;legacy s.yaml 也认),DROP 打两套前缀
|
|
367
|
+
it('s.int.yaml / s.prod.yaml 各 remove 一次(legacy s.yaml 与 s.prod.yaml 同指 prod 函数,有后者就不再对前者 remove);基础表名展开成 _int_ 与无标签两套 DROP', () => {
|
|
368
|
+
const log = join(box.root, 's-argv.log')
|
|
369
|
+
writeSShim(box.bin, log)
|
|
370
|
+
const { ext, persona } = removeFixture({ sFiles: ['s.int.yaml', 's.prod.yaml', 's.yaml'], tables: 'visits notes' })
|
|
371
|
+
const out = runRemove(ext, persona)
|
|
372
|
+
const argv = readFileSync(log, 'utf8')
|
|
373
|
+
expect(argv).toContain('remove -y -t s.int.yaml --access clawd-fc')
|
|
374
|
+
expect(argv).toContain('remove -y -t s.prod.yaml --access clawd-fc')
|
|
375
|
+
expect(argv).not.toContain('-t s.yaml ')
|
|
376
|
+
for (const t of ['demo_ab12_int_visits', 'demo_ab12_visits', 'demo_ab12_int_notes', 'demo_ab12_notes']) {
|
|
377
|
+
expect(out).toContain(`DROP TABLE IF EXISTS public.${t} CASCADE;`)
|
|
378
|
+
}
|
|
379
|
+
})
|
|
380
|
+
|
|
381
|
+
it('双环境之前的完整表名(带完整前缀 <APP_NAME>_<SLUG>_)只打一条;别的前缀一律按基础名展开,DROP 永远落在自己前缀下', () => {
|
|
382
|
+
const log = join(box.root, 's-argv.log')
|
|
383
|
+
writeSShim(box.bin, log)
|
|
384
|
+
const { ext, persona } = removeFixture({ sFiles: ['s.yaml'], tables: 'demo_ab12_visits' })
|
|
385
|
+
const out = runRemove(ext, persona)
|
|
386
|
+
expect(readFileSync(log, 'utf8')).toContain('remove -y -t s.yaml --access clawd-fc')
|
|
387
|
+
expect(out.match(/DROP TABLE/g)).toHaveLength(1)
|
|
388
|
+
expect(out).toContain('DROP TABLE IF EXISTS public.demo_ab12_visits CASCADE;')
|
|
389
|
+
writeFileSync(join(ext, 'ext.conf'), 'APP_NAME=demo\nSLUG=ab12\nSUPABASE_TABLES="demo_other_visits"\n')
|
|
390
|
+
const out2 = runRemove(ext, persona)
|
|
391
|
+
expect(out2).not.toContain('public.demo_other_visits ')
|
|
392
|
+
expect(out2.match(/DROP TABLE IF EXISTS public\.demo_ab12_(int_)?demo_other_visits CASCADE;/g)).toHaveLength(2)
|
|
393
|
+
})
|
|
394
|
+
|
|
395
|
+
// dataclaw 双环境之前 functionName 不带 slug:老 s.yaml 与 s.prod.yaml 指**不同**函数(name: 不同),两份都要拆
|
|
396
|
+
it('老 s.yaml 的 name 与 s.prod.yaml 不同(指不同函数):两份都 remove', () => {
|
|
397
|
+
const log = join(box.root, 's-argv.log')
|
|
398
|
+
writeSShim(box.bin, log)
|
|
399
|
+
const { ext, persona } = removeFixture({ sFiles: ['s.prod.yaml', 's.yaml'], names: { 's.yaml': 'demo-app', 's.prod.yaml': 'demo-ab12-app' } })
|
|
400
|
+
runRemove(ext, persona)
|
|
401
|
+
const argv = readFileSync(log, 'utf8')
|
|
402
|
+
expect(argv).toContain('remove -y -t s.prod.yaml --access clawd-fc')
|
|
403
|
+
expect(argv).toContain('remove -y -t s.yaml --access clawd-fc')
|
|
404
|
+
})
|
|
405
|
+
|
|
406
|
+
// #4:基础名恰好以 <APP_NAME>_ 开头(shop 的 shop_items)不能被当成 legacy 完整名——legacy 判据是完整前缀 <APP_NAME>_<SLUG>_
|
|
407
|
+
it('基础名以 <APP_NAME>_ 开头(shop_items)仍按基础名展开两套', () => {
|
|
408
|
+
writeSShim(box.bin, join(box.root, 's-argv.log'))
|
|
409
|
+
const { ext, persona } = removeFixture({ sFiles: ['s.prod.yaml'], tables: 'demo_items' })
|
|
410
|
+
const out = runRemove(ext, persona)
|
|
411
|
+
expect(out).toContain('DROP TABLE IF EXISTS public.demo_ab12_int_demo_items CASCADE;')
|
|
412
|
+
expect(out).toContain('DROP TABLE IF EXISTS public.demo_ab12_demo_items CASCADE;')
|
|
413
|
+
expect(out.match(/DROP TABLE/g)).toHaveLength(2)
|
|
414
|
+
})
|
|
415
|
+
|
|
416
|
+
it('从未发布过(没有任何 s 文件):不调 s remove,退出 0', () => {
|
|
417
|
+
const log = join(box.root, 's-argv.log')
|
|
418
|
+
writeSShim(box.bin, log)
|
|
419
|
+
const { ext, persona } = removeFixture({ sFiles: [] })
|
|
420
|
+
const out = runRemove(ext, persona)
|
|
421
|
+
expect(existsSync(log)).toBe(false)
|
|
422
|
+
expect(out).toContain('从未发布过')
|
|
423
|
+
})
|
|
303
424
|
})
|
|
@@ -33,8 +33,8 @@ SLUG="$(openssl rand -hex 2)"
|
|
|
33
33
|
echo ""
|
|
34
34
|
echo "# === 全局唯一身份 (scaffold 时锁定,不要改) ==="
|
|
35
35
|
echo "# APP_NAME = 项目名 (人类可读身份);SLUG = 4 位散列防同名冲突"
|
|
36
|
-
echo "# 派生:DEPLOY_NAME=\${APP_NAME}-\${SLUG} (FC 函数/子域名,DNS-safe
|
|
37
|
-
echo "# 表前缀=\${APP_NAME}_\${SLUG}_
|
|
36
|
+
echo "# 派生:DEPLOY_NAME=\${APP_NAME}-\${SLUG}[-int] (FC 函数/子域名,DNS-safe 用连字符;integration 带 -int)"
|
|
37
|
+
echo "# 表前缀=\${APP_NAME}_\${SLUG}_[int_] (SQL-safe 用下划线;integration 带 int_,server 经 env TABLE_PREFIX 取)"
|
|
38
38
|
echo "APP_NAME=${NAME}"
|
|
39
39
|
echo "SLUG=${SLUG}"
|
|
40
40
|
} >> "$DEST/ext.conf"
|
|
@@ -62,6 +62,10 @@ render_env_example() {
|
|
|
62
62
|
if [ ${#sed_args[@]} -eq 0 ]; then cp "$src" "$dst"; else sed "${sed_args[@]}" "$src" > "$dst"; fi
|
|
63
63
|
echo " 📄 渲染 ${dst#$DEST/}(值来自本次进程 env,不写回模板)"
|
|
64
64
|
}
|
|
65
|
+
# 本地 pnpm dev 一律连 integration 那套表(spec 2026-09-15-app-builder-integration-env-design §3.4):
|
|
66
|
+
# 这两个值在渲 .env 之前就地定死,不从进程 env 取——scaffold 的环境里就算有同名变量也不该漏进来。
|
|
67
|
+
export APP_ENV=integration
|
|
68
|
+
export TABLE_PREFIX="${NAME}_${SLUG}_int_"
|
|
65
69
|
while IFS= read -r f; do render_env_example "$f"; done < <(find "$DEST" -name '*.env.example' -not -path '*/node_modules/*')
|
|
66
70
|
|
|
67
71
|
echo "✅ 新 extension: $DEST (APP_NAME=${NAME}, SLUG=${SLUG})"
|
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
2
|
# ============================================================
|
|
3
3
|
# 通用一键发布 extension 到阿里云 FC(persona 无关)
|
|
4
|
-
# 用法: publish.sh <extension目录> <persona根目录>
|
|
4
|
+
# 用法: publish.sh <extension目录> <persona根目录> <env>
|
|
5
5
|
# - <persona根目录>: daemon 按 session persona 解析后传入(.../personas/<id>)
|
|
6
|
+
# - <env>: integration | prod(必填,无默认)。integration 发到 <APP_NAME>-<SLUG>-int(测试地址),
|
|
7
|
+
# prod 发到 <APP_NAME>-<SLUG>(老地址不变)。见 spec 2026-09-15-app-builder-integration-env-design
|
|
6
8
|
# - 平台凭证/脚本骨架/bootstrap 来自本脚本所在的共享 deploy-kit
|
|
7
9
|
# - persona 注入值/占位符/特化逻辑/persona 密钥来自 <persona根>/extension-kit
|
|
8
10
|
# ============================================================
|
|
9
11
|
set -euo pipefail
|
|
10
12
|
|
|
11
13
|
KIT_DIR="$(cd "$(dirname "$0")/.." && pwd)" # 共享 deploy-kit 根
|
|
12
|
-
EXT_DIR="${1:?用法: publish.sh <extension目录> <persona根目录>}"
|
|
14
|
+
EXT_DIR="${1:?用法: publish.sh <extension目录> <persona根目录> <env>}"
|
|
13
15
|
EXT_DIR="$(cd "$EXT_DIR" && pwd)"
|
|
14
16
|
PERSONA_ROOT="${2:?缺 persona 根目录}"
|
|
15
17
|
PERSONA_KIT="$PERSONA_ROOT/extension-kit"
|
|
@@ -17,6 +19,9 @@ PERSONA_KIT="$PERSONA_ROOT/extension-kit"
|
|
|
17
19
|
# 1) 配置:persona config.env(含平台值+注入值)+ extension 自身 ext.conf(覆盖)
|
|
18
20
|
[ -f "$PERSONA_KIT/config.env" ] && source "$PERSONA_KIT/config.env"
|
|
19
21
|
[ -f "$EXT_DIR/ext.conf" ] && source "$EXT_DIR/ext.conf"
|
|
22
|
+
# env 在 source 之后赋值:位置参数是唯一来源,config.env / ext.conf 里就算写了同名变量也盖不过它
|
|
23
|
+
APP_ENV="${3:?缺 env(integration|prod)}"
|
|
24
|
+
case "$APP_ENV" in integration|prod) ;; *) echo "❌ env 只能是 integration|prod,收到:$APP_ENV" >&2; exit 1;; esac
|
|
20
25
|
APP_NAME="${APP_NAME:-$(basename "$EXT_DIR")}"
|
|
21
26
|
CODE_DIR="${CODE_DIR:-./server}"
|
|
22
27
|
FC_ENTRY="${FC_ENTRY:-dist/main.js}"
|
|
@@ -26,15 +31,32 @@ BUILD_CMD="${BUILD_CMD:-}"
|
|
|
26
31
|
# 由 new-extension.sh 锁定 + bake 进 ext.conf。这里的 fallback 兜底两种历史情况:
|
|
27
32
|
# 1) 老项目 (本改动前 scaffold) ext.conf 无 SLUG → 现场生成 + bake,保后续稳定
|
|
28
33
|
# 2) 手动 rm SLUG 或损坏 ext.conf → 同上,fail-safe
|
|
29
|
-
#
|
|
30
|
-
# DEPLOY_NAME=${APP_NAME}-${SLUG} → FC functionName/子域名/s 项目名 (DNS-safe 连字符)
|
|
31
|
-
#
|
|
34
|
+
# 派生 (按 env):
|
|
35
|
+
# DEPLOY_NAME=${APP_NAME}-${SLUG}[-int] → FC functionName/子域名/s 项目名 (DNS-safe 连字符)
|
|
36
|
+
# TABLE_PREFIX=${APP_NAME}_${SLUG}_[int_] → Supabase 表 (SQL-safe 下划线,见 remove-extension.sh)
|
|
32
37
|
if [ -z "${SLUG:-}" ]; then
|
|
33
38
|
SLUG="$(openssl rand -hex 2)"
|
|
34
39
|
printf '\nSLUG=%s\n' "$SLUG" >> "$EXT_DIR/ext.conf"
|
|
35
40
|
echo " 📝 锁定 SLUG=$SLUG (含 4 位散列防同名冲突,已 bake 进 ext.conf)"
|
|
36
41
|
fi
|
|
37
|
-
|
|
42
|
+
BASE_NAME="${APP_NAME}-${SLUG}"
|
|
43
|
+
# 环境名 integration / prod 是人读的;进资源名的是短标签 ENV_TAG:int / (prod 为空,保持老地址不变)。
|
|
44
|
+
# 一眼看域名:带 -int 的就是测试环境。派生段与 publish.spec.ts 的 DERIVE_SNIPPET 同步。
|
|
45
|
+
if [ "$APP_ENV" = integration ]; then
|
|
46
|
+
ENV_TAG=int; DEPLOY_NAME="${BASE_NAME}-int"; TABLE_PREFIX="${APP_NAME}_${SLUG}_int_"
|
|
47
|
+
else
|
|
48
|
+
ENV_TAG=prod; DEPLOY_NAME="${BASE_NAME}"; TABLE_PREFIX="${APP_NAME}_${SLUG}_"
|
|
49
|
+
fi
|
|
50
|
+
# 两个环境各自的渲染产物;s 用 name: 键状态,DEPLOY_NAME 不同即隔离。
|
|
51
|
+
S_FILE="s.${ENV_TAG}.yaml"
|
|
52
|
+
echo " 🎯 env=$APP_ENV → $DEPLOY_NAME (表前缀 $TABLE_PREFIX, 渲到 $S_FILE)"
|
|
53
|
+
# 渲出来的 s 文件含 supabase key,靠项目 .gitignore 挡在仓库外。模板已有 s.*.yaml;双环境之前 scaffold 的
|
|
54
|
+
# 存量项目只有 s.yaml 一行,s.int.yaml 会被当源码推上 GitHub——像 bake SLUG 一样幂等地补一行(改了
|
|
55
|
+
# .gitignore 下次 publish 会被「先推再发」拦一次,推上去的只是 .gitignore,无害)。
|
|
56
|
+
if ! grep -qxF 's.*.yaml' "$EXT_DIR/.gitignore" 2>/dev/null; then
|
|
57
|
+
printf 's.*.yaml\n' >> "$EXT_DIR/.gitignore"
|
|
58
|
+
echo " 📝 .gitignore 补 s.*.yaml (渲出的 s.int.yaml / s.prod.yaml 含 key,不进仓库;记得把 .gitignore 推上去)"
|
|
59
|
+
fi
|
|
38
60
|
|
|
39
61
|
# 2) 工具链 + 阿里云凭证(分层:shared demo + 可选 local override)
|
|
40
62
|
source "$KIT_DIR/scripts/ensure-toolchain.sh"
|
|
@@ -59,6 +81,7 @@ cp "$KIT_DIR/contract/bootstrap" "$EXT_DIR/$CODE_DIR/bootstrap"
|
|
|
59
81
|
chmod +x "$EXT_DIR/$CODE_DIR/bootstrap"
|
|
60
82
|
|
|
61
83
|
# 6) 通用占位符替换渲染 s.yaml:扫 persona s.yaml.tmpl 的所有 __X__,从 env 取同名变量
|
|
84
|
+
#(APP_ENV / TABLE_PREFIX 也走这条:模板里 __APP_ENV__ / __TABLE_PREFIX__ 渲进 FC 的 environmentVariables)
|
|
62
85
|
#
|
|
63
86
|
# 自定义域名段按履约拼接(M1b Step 5c):APP_DOMAIN_SUFFIX 非空 = 部署级履约(域名与通配符
|
|
64
87
|
# 证书都在我们自己的阿里云账号上)→ 把 s.yaml.domain.tmpl 接上;空 = 使用者自带 key,
|
|
@@ -110,12 +133,12 @@ for ph in $(grep -oE '__[A-Z0-9_]+__' "$TMPL_RENDER" | sort -u); do
|
|
|
110
133
|
val="${val//&/\\&}"
|
|
111
134
|
sed_args+=(-e "s|$ph|$val|g")
|
|
112
135
|
done
|
|
113
|
-
sed "${sed_args[@]}" "$TMPL_RENDER" > "$EXT_DIR
|
|
136
|
+
sed "${sed_args[@]}" "$TMPL_RENDER" > "$EXT_DIR/$S_FILE"
|
|
114
137
|
|
|
115
138
|
# 7) deploy + verify
|
|
116
139
|
echo "::stage::deploy"
|
|
117
|
-
echo "==> s deploy ($DEPLOY_NAME)"
|
|
118
|
-
( cd "$EXT_DIR" && s deploy -y --access "$S_ACCESS_ALIAS" )
|
|
140
|
+
echo "==> s deploy ($DEPLOY_NAME, env=$APP_ENV)"
|
|
141
|
+
( cd "$EXT_DIR" && s deploy -y -t "$S_FILE" --access "$S_ACCESS_ALIAS" )
|
|
119
142
|
|
|
120
143
|
echo "::stage::verify"
|
|
121
144
|
# 产物 URL 怎么取,取决于**这个 persona 参不参与 5c 的域名段方案**(判据同上面渲染那步,
|
|
@@ -68,6 +68,63 @@ describe('publish.sh 占位符替换', () => {
|
|
|
68
68
|
})
|
|
69
69
|
})
|
|
70
70
|
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// 双环境(spec 2026-09-15-app-builder-integration-env-design §3.2):env 是脚本第 3 位置参数,
|
|
73
|
+
// 环境名 integration / prod 是人读的;进资源名的是短标签 ENV_TAG(int / prod 为空保持老地址)。
|
|
74
|
+
// 复刻 publish.sh 的派生段;实现若改脚本,本片段须同步。
|
|
75
|
+
const DERIVE_SNIPPET = `
|
|
76
|
+
set -euo pipefail
|
|
77
|
+
APP_NAME="$1"; SLUG="$2"
|
|
78
|
+
APP_ENV="\${3:?缺 env(integration|prod)}"
|
|
79
|
+
case "$APP_ENV" in integration|prod) ;; *) echo "❌ env 只能是 integration|prod,收到:$APP_ENV" >&2; exit 1;; esac
|
|
80
|
+
BASE_NAME="\${APP_NAME}-\${SLUG}"
|
|
81
|
+
if [ "$APP_ENV" = integration ]; then
|
|
82
|
+
ENV_TAG=int; DEPLOY_NAME="\${BASE_NAME}-int"; TABLE_PREFIX="\${APP_NAME}_\${SLUG}_int_"
|
|
83
|
+
else
|
|
84
|
+
ENV_TAG=prod; DEPLOY_NAME="\${BASE_NAME}"; TABLE_PREFIX="\${APP_NAME}_\${SLUG}_"
|
|
85
|
+
fi
|
|
86
|
+
S_FILE="s.\${ENV_TAG}.yaml"
|
|
87
|
+
printf '%s\\n%s\\n%s\\n%s\\n' "$ENV_TAG" "$DEPLOY_NAME" "$TABLE_PREFIX" "$S_FILE"
|
|
88
|
+
`
|
|
89
|
+
|
|
90
|
+
function derive(args: string[]): { ok: boolean; lines?: string[]; err?: string } {
|
|
91
|
+
try {
|
|
92
|
+
const out = execFileSync('bash', ['-c', DERIVE_SNIPPET, 'derive', ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] })
|
|
93
|
+
return { ok: true, lines: out.trim().split('\n') }
|
|
94
|
+
} catch (e) {
|
|
95
|
+
return { ok: false, err: (e as { stderr?: string }).stderr ?? '' }
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
describe('publish.sh 按 env 派生资源名', () => {
|
|
100
|
+
it('integration → ENV_TAG=int,DEPLOY_NAME / TABLE_PREFIX 带 int 标签,渲到 s.int.yaml', () => {
|
|
101
|
+
expect(derive(['demo', 'ab12', 'integration'])).toEqual({ ok: true, lines: ['int', 'demo-ab12-int', 'demo_ab12_int_', 's.int.yaml'] })
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('prod → 不带标签:DEPLOY_NAME / TABLE_PREFIX 与双环境之前完全一致(老地址不动),渲到 s.prod.yaml', () => {
|
|
105
|
+
expect(derive(['demo', 'ab12', 'prod'])).toEqual({ ok: true, lines: ['prod', 'demo-ab12', 'demo_ab12_', 's.prod.yaml'] })
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('缺 env / 非法 env:退 1,不猜默认', () => {
|
|
109
|
+
expect(derive(['demo', 'ab12']).ok).toBe(false)
|
|
110
|
+
const bad = derive(['demo', 'ab12', 'staging'])
|
|
111
|
+
expect(bad.ok).toBe(false)
|
|
112
|
+
expect(bad.err).toContain('integration|prod')
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('真脚本里的派生段与本片段一致(用法注释 + 第 3 参 + s deploy -t)', () => {
|
|
116
|
+
const real = readFileSync(join(__dirname, 'publish.sh'), 'utf8')
|
|
117
|
+
for (const line of [
|
|
118
|
+
'APP_ENV="${3:?',
|
|
119
|
+
'case "$APP_ENV" in integration|prod)',
|
|
120
|
+
'DEPLOY_NAME="${BASE_NAME}-int"',
|
|
121
|
+
'TABLE_PREFIX="${APP_NAME}_${SLUG}_int_"',
|
|
122
|
+
'S_FILE="s.${ENV_TAG}.yaml"',
|
|
123
|
+
's deploy -y -t "$S_FILE"',
|
|
124
|
+
]) expect(real, line).toContain(line)
|
|
125
|
+
})
|
|
126
|
+
})
|
|
127
|
+
|
|
71
128
|
// ---------------------------------------------------------------------------
|
|
72
129
|
// M1b Step 5c:账号级资源(自定义域名 + 证书)按履约切换
|
|
73
130
|
//
|
|
@@ -110,6 +167,7 @@ function renderSYaml(env: Record<string, string>): string {
|
|
|
110
167
|
APP_NAME: 'demo', DEPLOY_NAME: 'demo-ab12', CODE_DIR: './server', FC_ENTRY: 'dist/main.js',
|
|
111
168
|
FC_RUNTIME: 'custom.debian12', NODE_LAYER: 'layer', FC_MEMORY: '512', FC_TIMEOUT: '60',
|
|
112
169
|
SUPABASE_URL: 'http://sb', SUPABASE_KEY: 'anon',
|
|
170
|
+
APP_ENV: 'integration', TABLE_PREFIX: 'demo_ab12_int_',
|
|
113
171
|
})
|
|
114
172
|
expect(r.err ?? '').toBe('')
|
|
115
173
|
return r.out!
|
|
@@ -148,6 +206,12 @@ describe('两种履约渲染出来的 s.yaml 差异', () => {
|
|
|
148
206
|
expect(out).toContain('certId: 25612343')
|
|
149
207
|
})
|
|
150
208
|
|
|
209
|
+
it('APP_ENV / TABLE_PREFIX 渲进 FC 的 environmentVariables(server 只经它取表名)', () => {
|
|
210
|
+
const out = renderSYaml({})
|
|
211
|
+
expect(out).toContain('APP_ENV: integration')
|
|
212
|
+
expect(out).toContain('TABLE_PREFIX: demo_ab12_int_')
|
|
213
|
+
})
|
|
214
|
+
|
|
151
215
|
it('使用者自带 key:整段不渲染,只剩函数 + httpTrigger(走 FC 默认域名)', () => {
|
|
152
216
|
const out = renderSYaml({ APP_DOMAIN_SUFFIX: '' })
|
|
153
217
|
expect(out).not.toContain('fc3-domain')
|
|
@@ -181,6 +245,14 @@ describe('未参与 5c 的 persona(dataclaw-builder)不受影响', () => {
|
|
|
181
245
|
expect(text).not.toContain('__APP_DOMAIN_SUFFIX__')
|
|
182
246
|
})
|
|
183
247
|
|
|
248
|
+
it('functionName / 资源 key 走 __DEPLOY_NAME__,双环境的 -int 标签才跟得上(不然 integration 会覆盖 prod 函数)', () => {
|
|
249
|
+
const text = readFileSync(join(DC_KIT, 'contract', 's.yaml.tmpl'), 'utf8')
|
|
250
|
+
expect(text).toContain('functionName: __DEPLOY_NAME__')
|
|
251
|
+
expect(text).not.toContain('functionName: __APP_NAME__')
|
|
252
|
+
expect(text).toContain('APP_ENV: __APP_ENV__')
|
|
253
|
+
expect(text).toContain('TABLE_PREFIX: __TABLE_PREFIX__')
|
|
254
|
+
})
|
|
255
|
+
|
|
184
256
|
it('它的 config.env 里没有 APP_DOMAIN_SUFFIX(所以那个变量对它恒空)', () => {
|
|
185
257
|
expect(readFileSync(join(DC_KIT, 'config.env'), 'utf8')).not.toContain('APP_DOMAIN_SUFFIX')
|
|
186
258
|
})
|