@clawos-dev/clawd 0.2.520 → 0.2.522

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. package/dist/app-builder-plugin/mcp-server.cjs +65 -19
  2. package/dist/cli.cjs +20 -23
  3. package/dist/deploy-kit/scripts/ensure-credentials.spec.ts +139 -18
  4. package/dist/deploy-kit/scripts/new-extension.sh +6 -2
  5. package/dist/deploy-kit/scripts/publish.sh +32 -9
  6. package/dist/deploy-kit/scripts/publish.spec.ts +72 -0
  7. package/dist/deploy-kit/scripts/remove-extension.sh +36 -9
  8. package/dist/persona-defaults/persona-app-builder/.claude/skills/app-builder-projects/SKILL.md +20 -4
  9. package/dist/persona-defaults/persona-app-builder/.claude/skills/app-builder-projects/scripts/collect-files.mjs +2 -2
  10. package/dist/persona-defaults/persona-app-builder/.claude/skills/app-builder-projects/scripts/list-projects.mjs +4 -1
  11. package/dist/persona-defaults/persona-app-builder/.claude/skills/app-builder-projects/scripts/render-sql.mjs +64 -0
  12. package/dist/persona-defaults/persona-app-builder/CLAUDE.md +19 -14
  13. package/dist/persona-defaults/persona-app-builder/extension-kit/README.md +1 -1
  14. package/dist/persona-defaults/persona-app-builder/extension-kit/contract/s.yaml.tmpl +8 -3
  15. package/dist/persona-defaults/persona-app-builder/extension-kit/examples/nestjs-react/ext.conf +3 -1
  16. package/dist/persona-defaults/persona-app-builder/extension-kit/examples/nestjs-react/server/.env.example +4 -0
  17. package/dist/persona-defaults/persona-app-builder/extension-kit/examples/nestjs-react/server/.fcignore +1 -0
  18. package/dist/persona-defaults/persona-app-builder/extension-kit/examples/nestjs-react/server/db/schema.sql +15 -0
  19. package/dist/persona-defaults/persona-app-builder/extension-kit/examples/nestjs-react/server/src/main.ts +3 -1
  20. package/dist/persona-defaults/persona-app-builder/extension-kit/examples/nestjs-react/server/src/table-name.ts +19 -0
  21. package/dist/persona-defaults/persona-dataclaw-builder/.claude/skills/app-builder-projects/SKILL.md +20 -4
  22. package/dist/persona-defaults/persona-dataclaw-builder/.claude/skills/app-builder-projects/scripts/collect-files.mjs +2 -2
  23. package/dist/persona-defaults/persona-dataclaw-builder/.claude/skills/app-builder-projects/scripts/list-projects.mjs +4 -1
  24. package/dist/persona-defaults/persona-dataclaw-builder/.claude/skills/app-builder-projects/scripts/render-sql.mjs +64 -0
  25. package/dist/persona-defaults/persona-dataclaw-builder/CLAUDE.md +4 -2
  26. package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/README.md +1 -1
  27. package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/contract/s.yaml.tmpl +15 -6
  28. 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 = AppProjectMetadataSchema.safeParse(JSON.parse(raw));
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
- async setPublished(name, fields) {
21243
- return this.patch(name, fields);
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 prodUrl = null;
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") prodUrl = p.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 && prodUrl) {
25056
+ if (code === 0 && url) {
25017
25057
  const publishedAt = (/* @__PURE__ */ new Date()).toISOString();
25018
25058
  try {
25019
- await store.setPublished(name, {
25020
- prodUrl,
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, prodUrl, head });
25029
- resolve({ ok: true, prodUrl, commit: head, publishedAt, logPath });
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, prodUrl: r.prodUrl, commit: r.commit, publishedAt: r.publishedAt });
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 the public internet",
25205
- description: "Run the publish pipeline (build \u2192 deploy to Aliyun FC \u2192 verify) and wait for it to finish. Returns the public URL (and the commit it corresponds to) on success; on failure returns the failed stage, the error tail and the path of the full log. 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.",
25206
- inputSchema: { name: external_exports.string().min(1).describe("project name") }
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
@@ -67647,9 +67647,8 @@ var LoginFlow = class {
67647
67647
  try {
67648
67648
  await this.deps.upsertBinding({
67649
67649
  deviceId: this.deps.getDeviceId(),
67650
- unionId: identity.unionId,
67651
- displayName: identity.displayName,
67652
- avatarUrl: identity.avatarUrl ?? null
67650
+ ttcToken: params.token,
67651
+ identity
67653
67652
  });
67654
67653
  } catch (err) {
67655
67654
  return {
@@ -67736,22 +67735,19 @@ async function upsertDeviceBinding(opts) {
67736
67735
  const doFetch = opts.fetchImpl ?? fetch;
67737
67736
  const ac = new AbortController();
67738
67737
  const timer = setTimeout(() => ac.abort(), opts.timeoutMs ?? DEFAULT_TIMEOUT_MS4);
67739
- const body = { unionId: opts.unionId };
67740
- if (opts.avatarUrl != null) body.avatarUrl = opts.avatarUrl;
67741
67738
  let res;
67742
67739
  try {
67743
- res = await doFetch(`${opts.apiUrl}/api/device-bindings/self`, {
67744
- method: "PUT",
67745
- signal: ac.signal,
67746
- headers: {
67747
- authorization: `Bearer ${opts.apiKey}`,
67748
- "x-did": encodeURIComponent(opts.deviceId),
67749
- "x-name": encodeURIComponent(opts.displayName),
67750
- "x-scene": "daemon",
67751
- "content-type": "application/json"
67752
- },
67753
- body: JSON.stringify(body)
67754
- });
67740
+ res = await doFetch(
67741
+ `${opts.apiUrl}/api/device-bindings/self?provider=${encodeURIComponent(opts.provider)}`,
67742
+ {
67743
+ method: "PUT",
67744
+ signal: ac.signal,
67745
+ headers: {
67746
+ authorization: `Bearer ${opts.ttcToken}`,
67747
+ "x-did": encodeURIComponent(opts.deviceId)
67748
+ }
67749
+ }
67750
+ );
67755
67751
  } catch (err) {
67756
67752
  clearTimeout(timer);
67757
67753
  throw new DeviceBindingClientError(
@@ -70380,7 +70376,7 @@ function computeMethodAccess(args) {
70380
70376
  }
70381
70377
 
70382
70378
  // src/version.ts
70383
- var version = "0.2.520".length > 0 ? "0.2.520" : "dev";
70379
+ var version = "0.2.522".length > 0 ? "0.2.522" : "dev";
70384
70380
 
70385
70381
  // src/cli-probe/probe.ts
70386
70382
  var fs77 = __toESM(require("fs"), 1);
@@ -73821,24 +73817,25 @@ async function startDaemon(config) {
73821
73817
  ttcAuthBase: TTC_HOSTS.auth,
73822
73818
  getCallbackUrl: () => `http://127.0.0.1:${config.port}/auth/callback`,
73823
73819
  // device_bindings upsert(did ↔ 飞书 unionId 映射):
73824
- // deviceId 从 auth.json 稳定读;apiUrl 走同一份 clawosApi;apiKey 复用 CLAWD_TICKETS_API_KEY。
73820
+ // deviceId 从 auth.json 稳定读;apiUrl 走同一份 clawosApi;鉴权 = 刚登录拿到的 TTC JWT。
73825
73821
  // 失败即登录失败(fail-hard,login-flow.spec §upsert 失败)。
73826
73822
  getDeviceId: () => authFile.deviceId,
73827
73823
  upsertBinding: async (opts) => {
73828
73824
  try {
73829
73825
  await upsertDeviceBinding({
73830
73826
  apiUrl: config.clawosApi ?? DEFAULT_CLAWOS_API,
73831
- apiKey: resolveClawdTicketsApiKey(),
73832
- ...opts
73827
+ ttcToken: opts.ttcToken,
73828
+ provider: opts.identity.provider,
73829
+ deviceId: opts.deviceId
73833
73830
  });
73834
73831
  logger.info("device-binding upsert succeeded", {
73835
73832
  deviceId: opts.deviceId,
73836
- unionId: opts.unionId
73833
+ unionId: opts.identity.unionId
73837
73834
  });
73838
73835
  } catch (err) {
73839
73836
  logger.warn("device-binding upsert failed", {
73840
73837
  deviceId: opts.deviceId,
73841
- unionId: opts.unionId,
73838
+ unionId: opts.identity.unionId,
73842
73839
  error: err instanceof Error ? err.message : String(err)
73843
73840
  });
73844
73841
  throw err;
@@ -249,14 +249,11 @@ printf 'id=%s\\n' "$ALIBABA_CLOUD_ACCESS_KEY_ID"`,
249
249
  })
250
250
 
251
251
  describe('publish.sh', () => {
252
- it('用 clawd-fc profile 调 s deploy,不碰用户全局 ~/.s', () => {
253
- const log = join(box.root, 's-argv.log')
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
- // 不影响本用例要断言的凭证行为,省掉 curl shim。
257
- const aliyunLog = join(box.root, 'aliyun-argv.log')
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(join(persona, 'extension-kit', 'contract', 's.yaml.tmpl'), 'name: __DEPLOY_NAME__-app\naccess: clawd-fc\n')
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
- expect(readFileSync(log, 'utf8')).toContain('deploy -y --access clawd-fc')
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
- describe('remove-extension.sh', () => {
284
- it('用 clawd-fc profile 调 s remove,不碰用户全局 ~/.s', () => {
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'), 'APP_NAME=demo\nSLUG=ab12\n')
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
- execFileSync('bash', [join(box.kit, 'scripts', 'remove-extension.sh'), ext, persona], {
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
- expect(readFileSync(log, 'utf8')).toContain('remove -y --access clawd-fc')
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}_ (SQL-safe 用下划线)"
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
- # 表前缀=${APP_NAME}_${SLUG}_ → Supabase 表 (SQL-safe 下划线,见 remove-extension.sh)
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
- DEPLOY_NAME="${APP_NAME}-${SLUG}"
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/s.yaml"
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 的域名段方案**(判据同上面渲染那步,