@aipt/idp-deploy 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -72,31 +72,29 @@ Bench 原有部署资产已按能力而非目录整体接收:受控运维语
72
72
  export IDP_DEPLOY_ROOT=/Users/dyyto/Desktop/works/sources/spaces/idp-deploy
73
73
  export APPLICATION_ROOT=/absolute/path/to/new-project
74
74
  export IDP_CONFIG_DIR=/Users/Shared/company/idp/config
75
+ export APP_CONFIG_DIR=/Users/Shared/company/app/config
75
76
 
76
77
  # 1. 根据Dyyto ApplicationConfigContract创建应用.env、secrets/和certs/;重入不覆盖已有值。
77
78
  node "$IDP_DEPLOY_ROOT/bin/idpctl.mjs" app config-init \
78
79
  --project "$APPLICATION_ROOT" \
79
- --config-dir "$IDP_CONFIG_DIR"
80
+ --environment local
80
81
 
81
82
  # 2. 填写命令不能代替外部系统签发的required Secret;不要把值写入项目。
82
- chmod 600 "$IDP_CONFIG_DIR/applications/<application-id>/secrets/"*
83
+ chmod 600 "$APP_CONFIG_DIR/<application-id>/local/secrets/"*
83
84
 
84
85
  # 3. 生成不可变Local DeploymentPlan;本步只执行docker compose config和安全检查。
85
86
  node "$IDP_DEPLOY_ROOT/bin/idpctl.mjs" local app plan \
86
87
  --project "$APPLICATION_ROOT" \
87
88
  --compose-file "$APPLICATION_ROOT/deploy/compose.local.yaml" \
88
- --output "$IDP_CONFIG_DIR/plans/local-applications/<application-id>-local.json" \
89
- --config-dir "$IDP_CONFIG_DIR"
89
+ --output "$APP_CONFIG_DIR/<application-id>/local/plans/local-applications/<application-id>-local.json"
90
90
 
91
91
  # 4. 审查Plan中的项目、输入摘要、Compose摘要和promotable:false后执行。
92
92
  node "$IDP_DEPLOY_ROOT/bin/idpctl.mjs" local app apply \
93
- --plan "$IDP_CONFIG_DIR/plans/local-applications/<application-id>-local.json" \
94
- --config-dir "$IDP_CONFIG_DIR"
93
+ --plan "$APP_CONFIG_DIR/<application-id>/local/plans/local-applications/<application-id>-local.json"
95
94
 
96
95
  # 5. 随时重新核验精确Plan与容器健康状态。
97
96
  node "$IDP_DEPLOY_ROOT/bin/idpctl.mjs" local app verify \
98
- --plan "$IDP_CONFIG_DIR/plans/local-applications/<application-id>-local.json" \
99
- --config-dir "$IDP_CONFIG_DIR"
97
+ --plan "$APP_CONFIG_DIR/<application-id>/local/plans/local-applications/<application-id>-local.json"
100
98
  ```
101
99
 
102
100
  代码、Dockerfile、Compose、`.env` 或任一 `*_FILE` 发生变化后,旧 Plan 会 fail-closed;删除旧 Plan 文件并用新的输出文件名重新 `plan`、审查和 `apply`。停止保留容器,移除不删除 Volume:
@@ -363,6 +361,7 @@ node bin/idpctl.mjs sources import bench \
363
361
 
364
362
  ```bash
365
363
  export IDP_CONFIG_DIR=/Users/Shared/company/idp/config
364
+ export APP_CONFIG_DIR=/Users/Shared/company/app/config
366
365
  node bin/idpctl.mjs workbench projects init
367
366
  ```
368
367
 
@@ -376,21 +375,15 @@ node bin/idpctl.mjs workbench projects init
376
375
  "id": "apex",
377
376
  "hostPath": "/Users/dyyto/Desktop/works/sources/spaces/apex",
378
377
  "configBindings": [
379
- {
380
- "relativePath": "apps/client",
381
- "applicationConfigDir": "/Users/Shared/company/apps/apex-client/local"
382
- },
383
- {
384
- "relativePath": "apps/server",
385
- "applicationConfigDir": "/Users/Shared/company/apps/apex-server/local"
386
- }
378
+ { "relativePath": "apps/client" },
379
+ { "relativePath": "apps/server" }
387
380
  ]
388
381
  }
389
382
  ]
390
383
  }
391
384
  ```
392
385
 
393
- Config Dir 应先由 `app config-init` 创建并由操作者填写 `.env`、`secrets/` 和 `certs/`。随后生成不可变 Plan、应用并验证:
386
+ `APP_CONFIG_DIR` 是业务配置总根;上例自动派生 `apex/apps/client/local` 和 `apex/apps/server/local`。各 Config Dir 应先由 `app config-init --workspace-root <apex-root>` 创建并由操作者填写 `.env`、`secrets/` 和 `certs/`。随后生成不可变 Plan、应用并验证:
394
387
 
395
388
  ```bash
396
389
  node bin/idpctl.mjs workbench projects plan \
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aipt/idp-deploy",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "IDP 私有化部署、外部配置、备份恢复与交付编排 CLI。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,50 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { invariant } from './errors.mjs';
4
+ import { assertOutsideRepositories, secureDirectoryRoot } from './security.mjs';
5
+
6
+ const ID = /^[a-z0-9][a-z0-9._-]{0,127}$/u;
7
+ const UNIT = /^(?:apps|packages)\/[a-z0-9][a-z0-9._-]*$/u;
8
+
9
+ function manifest(root) {
10
+ try { return JSON.parse(fs.readFileSync(path.join(root, 'project-capabilities.json'), 'utf8')); }
11
+ catch { return null; }
12
+ }
13
+
14
+ export function discoverWorkspace(projectRoot, explicitWorkspaceRoot) {
15
+ const project = fs.realpathSync.native(projectRoot);
16
+ if (explicitWorkspaceRoot) {
17
+ invariant(path.isAbsolute(explicitWorkspaceRoot), 'IDP_WORKSPACE_ROOT_NOT_ABSOLUTE', '--workspace-root必须是绝对路径');
18
+ const root = fs.realpathSync.native(explicitWorkspaceRoot), relative = path.relative(root, project);
19
+ invariant((relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative))) && manifest(root)?.profile?.id === 'workspace-root', 'IDP_WORKSPACE_ROOT_INVALID', '--workspace-root必须是包含项目的Dyyto Workspace Root');
20
+ return relative === '' ? null : { root, id: path.basename(root), unitPath: relative.split(path.sep).join('/') };
21
+ }
22
+ let cursor = path.dirname(project);
23
+ while (cursor !== path.dirname(cursor)) {
24
+ if (manifest(cursor)?.profile?.id === 'workspace-root') {
25
+ const unitPath = path.relative(cursor, project).split(path.sep).join('/');
26
+ return { root: cursor, id: path.basename(cursor), unitPath };
27
+ }
28
+ cursor = path.dirname(cursor);
29
+ }
30
+ return null;
31
+ }
32
+
33
+ export function resolveApplicationConfigDirectory({ projectRoot, applicationId, appConfigRoot, applicationConfigRoot, workspaceRoot, environment = 'local', create = false }) {
34
+ invariant(ID.test(applicationId ?? ''), 'IDP_APPLICATION_CONFIG_IDENTITY_INVALID', 'Application identity无效');
35
+ invariant(ID.test(environment), 'IDP_APPLICATION_ENVIRONMENT_INVALID', '应用环境名称无效');
36
+ if (applicationConfigRoot) {
37
+ const app = secureDirectoryRoot(applicationConfigRoot, '应用最终Config Dir', { create }); assertOutsideRepositories(app, '应用最终Config Dir');
38
+ return { root: app, app, environment, workspace: null, relativeDirectory: '.' };
39
+ }
40
+ invariant(path.isAbsolute(appConfigRoot ?? ''), 'APP_CONFIG_DIR_REQUIRED', '请设置绝对APP_CONFIG_DIR或传入--application-config-dir');
41
+ const root = secureDirectoryRoot(appConfigRoot, 'APP_CONFIG_DIR', { create }); assertOutsideRepositories(root, 'APP_CONFIG_DIR');
42
+ const workspace = discoverWorkspace(projectRoot, workspaceRoot);
43
+ if (workspace) invariant(UNIT.test(workspace.unitPath), 'IDP_WORKSPACE_UNIT_INVALID', 'Workspace应用必须位于apps/<name>或packages/<name>');
44
+ const relativeDirectory = workspace ? `${workspace.id}/${workspace.unitPath}/${environment}` : `${applicationId}/${environment}`;
45
+ const app = path.resolve(root, ...relativeDirectory.split('/'));
46
+ invariant(app.startsWith(`${root}${path.sep}`), 'APP_CONFIG_DIRECTORY_ESCAPE', '应用Config Dir逃逸APP_CONFIG_DIR');
47
+ if (create) secureDirectoryRoot(app, '应用Config Dir', { create: true });
48
+ else secureDirectoryRoot(app, '应用Config Dir');
49
+ return { root: app, app, environment, workspace, relativeDirectory };
50
+ }
package/src/cli.mjs CHANGED
@@ -30,6 +30,18 @@ export function configRootFrom(args, { environment = process.env } = {}) {
30
30
  return path.resolve(value);
31
31
  }
32
32
 
33
+ export function appConfigRootFrom(args, { environment = process.env, required = false } = {}) {
34
+ const index = args.indexOf('--app-config-dir');
35
+ if (index >= 0 && (!args[index + 1] || args[index + 1].startsWith('--'))) throw new IdpError('APP_CONFIG_ARGUMENT_MISSING', '--app-config-dir后必须提供绝对路径');
36
+ const value = index >= 0 ? args[index + 1] : environment.APP_CONFIG_DIR;
37
+ if (!value) {
38
+ if (required) throw new IdpError('APP_CONFIG_DIR_REQUIRED', '请设置APP_CONFIG_DIR或传入--app-config-dir');
39
+ return undefined;
40
+ }
41
+ if (!path.isAbsolute(value)) throw new IdpError('APP_CONFIG_DIR_NOT_ABSOLUTE', 'APP_CONFIG_DIR必须是绝对路径');
42
+ return path.resolve(value);
43
+ }
44
+
33
45
  export function readSourceImportFile(configRoot, rawCandidate) {
34
46
  if (!path.isAbsolute(rawCandidate ?? '')) throw new IdpError('IDP_SOURCE_FILE_NOT_ABSOLUTE', '--file必须是原始绝对路径');
35
47
  const { root } = doctorConfig(configRoot, { requireConfigured: false, profile: 'registry' });
@@ -72,7 +84,7 @@ function print(value) {
72
84
  export async function main(args) {
73
85
  const [command, subcommand] = args;
74
86
  if (!command || ['help', '--help', '-h'].includes(command)) {
75
- process.stdout.write('本地项目命令:\n app config-init --project <绝对项目目录>\n local app plan --project <绝对目录> --compose-file <绝对文件> --output <IDP_CONFIG_DIR/plans/local-applications/...>\n local app apply|verify|stop --plan <绝对文件>\n local app remove --plan <绝对文件> --confirm <application-id>\n\n');
87
+ process.stdout.write('本地项目命令:\n app config-init --project <绝对项目目录> [--workspace-root <绝对目录>] [--environment local]\n local app plan --project <绝对目录> [--workspace-root <绝对目录>] --compose-file <绝对文件> --output <应用Config Dir/plans/local-applications/...>\n local app apply|verify|stop --plan <绝对文件>\n local app remove --plan <绝对文件> --confirm <application-id>\n\nAPP_CONFIG_DIR是全部业务工作区/应用配置总根;显式--application-config-dir仅兼容覆盖最终应用目录。\n\n');
76
88
  process.stdout.write('Docker工作台项目命令:\n workbench projects init\n workbench projects plan --dyyto-root <绝对目录> --file <candidate> --output <Plan>\n workbench projects apply --plan <Plan>\n workbench projects verify\n\n');
77
89
  print(`idpctl:私有IDP部署与运维工具\n\n命令:\n config init\n config generate-secrets\n config snapshot\n release deploy [profile] [--version <semver>] [--oci-repository-root <host/namespace/repository>]\n gitops app plan-create|plan-update|plan-remove --component-contract <绝对路径> --environment-binding <绝对路径> --release-candidate <绝对路径> --gitops-root <绝对路径> --output <IDP_CONFIG_DIR/plans/...>\n gitops app apply --plan <绝对路径>\n gitops app verify --application <名称> --environment <名称> --gitops-root <绝对路径>\n foundation doctor|verify [--context <kube-context>]\n foundation directory sync [--argo-url <URL>] [--rollouts-url <URL>] [--registry-url <URL>]\n sources import dyyto|bench --stdin\n sources import dyyto|bench --file <IDP_CONFIG_DIR/imports/...>\n images lock\n doctor [profile] [--require-configured]\n compose <profile>\n pull <profile>\n up <profile>\n switch <profile>\n down <profile>\n verify <profile>\n bindings sync <active-profile>\n accept mac-m4 <profile> --confirm-restart <IDP_INSTANCE_ID>\n backup create [profile]\n backup verify <generation-id>\n restore test <generation-id>\n restore apply <generation-id>\n restore verify <candidate-id>\n restore promote <candidate-id> --confirm <candidate-id>\n contracts verify config-release|release-candidate|deployment-evidence --file <绝对JSON路径>\n\nProfile:registry、tech-only、foundation、flow、portal、core、full、business-smartgo、core-smartgo;release deploy默认foundation。SmartGo不会进入默认core/full,需显式选择business-smartgo或core-smartgo。\n所有命令只通过IDP_CONFIG_DIR读取仓库外配置;Secret不会打印到终端。GitOps写入必须来自不可变Plan,Apply复验输入、HEAD与Preimage并在外部配置根保存恢复证据。release deploy在同一实例Lease内完成自研镜像的amd64/arm64构建与digest绑定、已有部署备份和隔离恢复演练、镜像锁、Compose、Verifier与Binding事务。bindings sync只从已启用且verify通过的当前运行事实生成Bench ServiceBinding,并保护用户修改与历史Preimage。跨Profile必须使用switch,它会显式清理旧Profile Orphan。restore apply先生成隔离候选;promote保留物理或逻辑Preimage并在失败时自动回滚。`);
78
90
  process.stdout.write('无集群开发者命令:\n app inspect --component-contract <绝对路径> [--environment-binding <绝对路径>]\n workflow generate|verify --file <绝对路径>\n build plan --component-contract <绝对路径> --repository <Git URL> --commit <40位SHA> --image-repository <OCI仓库> --output <绝对路径>\n build trigger --plan <绝对路径> --provider manual --output <绝对路径>\n build status --trigger <绝对路径>\n附加只读命令:\n lifecycle check\n contracts verify ...\n');
@@ -131,19 +143,20 @@ export async function main(args) {
131
143
  return;
132
144
  }
133
145
  const configRoot = configRootFrom(args);
146
+ const appConfigRoot = appConfigRootFrom(args);
134
147
  if (command === 'app' && subcommand === 'config-init') {
135
- const values = namedArguments(args.slice(2), ['--project', '--config-dir', '--application-config-dir']);
148
+ const values = namedArguments(args.slice(2), ['--project', '--config-dir', '--app-config-dir', '--application-config-dir', '--workspace-root', '--environment']);
136
149
  invariantNamed(values, '--project', 'app config-init');
137
- print(initializeApplicationConfig({ configRoot, projectRoot: values['--project'], applicationConfigRoot: values['--application-config-dir'] }));
150
+ print(initializeApplicationConfig({ configRoot, appConfigRoot, projectRoot: values['--project'], applicationConfigRoot: values['--application-config-dir'], workspaceRoot: values['--workspace-root'], environment: values['--environment'] ?? 'local' }));
138
151
  return;
139
152
  }
140
153
  if (command === 'workbench' && subcommand === 'projects') {
141
154
  const action = args[2];
142
- const values = namedArguments(args.slice(3), ['--dyyto-root', '--file', '--output', '--plan', '--config-dir']);
155
+ const values = namedArguments(args.slice(3), ['--dyyto-root', '--file', '--output', '--plan', '--config-dir', '--app-config-dir']);
143
156
  if (action === 'init') print(initializeWorkbenchProjects({ configRoot }));
144
157
  else if (action === 'plan') {
145
158
  for (const key of ['--dyyto-root', '--file', '--output']) invariantNamed(values, key, 'workbench projects plan');
146
- print(createWorkbenchProjectsPlan({ configRoot, dyytoRoot: values['--dyyto-root'], candidateFile: values['--file'], output: values['--output'] }));
159
+ print(createWorkbenchProjectsPlan({ configRoot, appConfigRoot, dyytoRoot: values['--dyyto-root'], candidateFile: values['--file'], output: values['--output'] }));
147
160
  } else if (action === 'apply') { invariantNamed(values, '--plan', 'workbench projects apply'); print(applyWorkbenchProjectsPlan({ configRoot, planFile: values['--plan'] })); }
148
161
  else if (action === 'verify') print(verifyWorkbenchProjects({ configRoot }));
149
162
  else throw new IdpError('IDP_WORKBENCH_ACTION_UNKNOWN', `未知工作台项目动作:${action ?? ''}`);
@@ -151,17 +164,17 @@ export async function main(args) {
151
164
  }
152
165
  if (command === 'local' && subcommand === 'app') {
153
166
  const action = args[2];
154
- const values = namedArguments(args.slice(3), ['--project', '--compose-file', '--output', '--plan', '--confirm', '--config-dir', '--application-config-dir']);
167
+ const values = namedArguments(args.slice(3), ['--project', '--workspace-root', '--environment', '--compose-file', '--output', '--plan', '--confirm', '--config-dir', '--app-config-dir', '--application-config-dir']);
155
168
  if (action === 'plan') {
156
169
  for (const key of ['--project', '--compose-file', '--output']) invariantNamed(values, key, 'local app plan');
157
- print(createLocalSourcePlan({ configRoot, applicationConfigRoot: values['--application-config-dir'], projectRoot: values['--project'], composeFile: values['--compose-file'], output: values['--output'] }));
170
+ print(createLocalSourcePlan({ configRoot, appConfigRoot, applicationConfigRoot: values['--application-config-dir'], projectRoot: values['--project'], workspaceRoot: values['--workspace-root'], environment: values['--environment'] ?? 'local', composeFile: values['--compose-file'], output: values['--output'] }));
158
171
  return;
159
172
  }
160
173
  invariantNamed(values, '--plan', `local app ${action ?? ''}`);
161
- if (action === 'apply') print(applyLocalSourceDeployment({ configRoot, applicationConfigRoot: values['--application-config-dir'], planFile: values['--plan'] }));
162
- else if (action === 'verify') print(verifyLocalSourceDeployment({ configRoot, applicationConfigRoot: values['--application-config-dir'], planFile: values['--plan'] }));
163
- else if (action === 'stop') print(stopLocalSourceDeployment({ configRoot, applicationConfigRoot: values['--application-config-dir'], planFile: values['--plan'] }));
164
- else if (action === 'remove') print(removeLocalSourceDeployment({ configRoot, applicationConfigRoot: values['--application-config-dir'], planFile: values['--plan'], confirmation: values['--confirm'] }));
174
+ if (action === 'apply') print(applyLocalSourceDeployment({ configRoot, appConfigRoot, applicationConfigRoot: values['--application-config-dir'], planFile: values['--plan'] }));
175
+ else if (action === 'verify') print(verifyLocalSourceDeployment({ configRoot, appConfigRoot, applicationConfigRoot: values['--application-config-dir'], planFile: values['--plan'] }));
176
+ else if (action === 'stop') print(stopLocalSourceDeployment({ configRoot, appConfigRoot, applicationConfigRoot: values['--application-config-dir'], planFile: values['--plan'] }));
177
+ else if (action === 'remove') print(removeLocalSourceDeployment({ configRoot, appConfigRoot, applicationConfigRoot: values['--application-config-dir'], planFile: values['--plan'], confirmation: values['--confirm'] }));
165
178
  else throw new IdpError('IDP_LOCAL_ACTION_UNKNOWN', `未知本地应用动作:${action ?? ''}`);
166
179
  return;
167
180
  }
@@ -4,6 +4,7 @@ import { IdpError, invariant } from './errors.mjs';
4
4
  import { hashFile, sha256, stableJson } from './hash.mjs';
5
5
  import { run } from './process.mjs';
6
6
  import { assertOutsideRepositories, assertSafeRegularFile, atomicWrite, canonicalPlannedDirectory, parseEnv, resolveContained, secureDirectoryRoot } from './security.mjs';
7
+ import { resolveApplicationConfigDirectory } from './application-config-location.mjs';
7
8
 
8
9
  const APPLICATION_ID = /^[a-z0-9][a-z0-9._-]{0,127}$/u;
9
10
  const ENV_NAME = /^[A-Z][A-Z0-9_]{0,127}$/u;
@@ -72,12 +73,8 @@ function validateApplicationConfigContract(contract, manifest) {
72
73
  return contract;
73
74
  }
74
75
 
75
- function applicationDirectory(configRoot, id, { create = false, applicationConfigRoot } = {}) {
76
- if (applicationConfigRoot) {
77
- const app = secureDirectoryRoot(applicationConfigRoot, 'APP_CONFIG_DIR', { create });
78
- assertOutsideRepositories(app, 'APP_CONFIG_DIR');
79
- return { root: app, app };
80
- }
76
+ function applicationDirectory(configRoot, id, { create = false, applicationConfigRoot, appConfigRoot, projectRoot, workspaceRoot, environment = 'local' } = {}) {
77
+ if (applicationConfigRoot || appConfigRoot) return resolveApplicationConfigDirectory({ projectRoot, applicationId: id, appConfigRoot, applicationConfigRoot, workspaceRoot, environment, create });
81
78
  const root = secureDirectoryRoot(configRoot, 'IDP_CONFIG_DIR', { create });
82
79
  assertOutsideRepositories(root, 'IDP_CONFIG_DIR');
83
80
  const applications = resolveContained(root, 'applications', '应用配置目录');
@@ -96,9 +93,9 @@ function savePreimage(root, id, name, bytes) {
96
93
  return { path: candidate, digest };
97
94
  }
98
95
 
99
- export function initializeApplicationConfig({ configRoot, projectRoot, applicationConfigRoot }) {
96
+ export function initializeApplicationConfig({ configRoot, appConfigRoot, projectRoot, applicationConfigRoot, workspaceRoot, environment = 'local' }) {
100
97
  const project = readProject(projectRoot);
101
- const { root, app } = applicationDirectory(configRoot, project.contract.application.id, { create: true, applicationConfigRoot });
98
+ const { root, app, relativeDirectory, workspace } = applicationDirectory(configRoot, project.contract.application.id, { create: true, applicationConfigRoot, appConfigRoot, projectRoot: project.root, workspaceRoot, environment });
102
99
  for (const name of ['secrets', 'certs']) secureDirectoryRoot(path.join(app, name), `应用${name}目录`, { create: true });
103
100
  const envFile = path.join(app, '.env');
104
101
  const before = fs.existsSync(envFile) ? fs.readFileSync(envFile) : Buffer.from('');
@@ -121,11 +118,11 @@ export function initializeApplicationConfig({ configRoot, projectRoot, applicati
121
118
  preimage = savePreimage(root, project.contract.application.id, 'env', before);
122
119
  atomicWrite(envFile, `${before.toString('utf8').trimEnd()}\n# 由app config-init补齐;已有值未覆盖。\n${additions.join('\n')}\n`, 0o600);
123
120
  }
124
- return { schemaVersion: 'idp.application-config-materialization/v1', status: 'ready', application: project.contract.application.id, directory: app, environmentFile: envFile, createdFiles, additions: additions.map((line) => line.slice(0, line.indexOf('='))), ...(preimage ? { preimage } : {}) };
121
+ return { schemaVersion: 'idp.application-config-materialization/v1', status: 'ready', application: project.contract.application.id, environment, directory: app, relativeDirectory, workspace: workspace ? { id: workspace.id, root: workspace.root, unitPath: workspace.unitPath } : null, environmentFile: envFile, createdFiles, additions: additions.map((line) => line.slice(0, line.indexOf('='))), ...(preimage ? { preimage } : {}) };
125
122
  }
126
123
 
127
- function inspectApplicationConfig(configRoot, contract, applicationConfigRoot) {
128
- const { app } = applicationDirectory(configRoot, contract.application.id, { applicationConfigRoot });
124
+ function inspectApplicationConfig(configRoot, contract, { applicationConfigRoot, appConfigRoot, projectRoot, workspaceRoot, environment = 'local' }) {
125
+ const { app } = applicationDirectory(configRoot, contract.application.id, { applicationConfigRoot, appConfigRoot, projectRoot, workspaceRoot, environment });
129
126
  const envFile = path.join(app, '.env');
130
127
  assertSafeRegularFile(envFile, 0o600, { allowEmpty: contract.bindings.length === 0, role: '应用.env' });
131
128
  const env = parseEnv(fs.readFileSync(envFile, 'utf8'), envFile);
@@ -152,16 +149,16 @@ function executeCompose(runner, args, cwd, capture = true) {
152
149
  return runner('docker', args, { cwd, capture, maxBuffer: 16 * 1024 * 1024 });
153
150
  }
154
151
 
155
- function normalizedCompose({ projectRoot, composeFile, envFile, composeProject, runner }) {
156
- const result = executeCompose(runner, ['compose', '--project-name', composeProject, '--env-file', envFile, '--file', composeFile, 'config', '--format', 'json'], projectRoot, true);
152
+ function normalizedCompose({ projectRoot, sourceRoot, composeFile, envFile, composeProject, runner }) {
153
+ const result = executeCompose(runner, ['compose', '--project-name', composeProject, '--env-file', envFile, '--file', composeFile, 'config', '--format', 'json'], sourceRoot, true);
157
154
  let document;
158
155
  try { document = JSON.parse(result.stdout); }
159
156
  catch (error) { throw new IdpError('IDP_LOCAL_COMPOSE_JSON_INVALID', 'docker compose未返回有效JSON', { cause: error.message }); }
160
- validateCompose(document, projectRoot);
157
+ validateCompose(document, projectRoot, sourceRoot);
161
158
  return { document, bytes: `${stableJson(document)}\n` };
162
159
  }
163
160
 
164
- function validateCompose(document, projectRoot) {
161
+ function validateCompose(document, projectRoot, sourceRoot) {
165
162
  const services = document?.services;
166
163
  invariant(services && typeof services === 'object' && Object.keys(services).length > 0, 'IDP_LOCAL_COMPOSE_INVALID', 'Compose必须至少声明一个服务');
167
164
  let healthchecks = 0;
@@ -173,9 +170,9 @@ function validateCompose(document, projectRoot) {
173
170
  if (service.build) {
174
171
  const context = typeof service.build === 'string' ? service.build : service.build.context;
175
172
  invariant(typeof context === 'string', 'IDP_LOCAL_COMPOSE_BUILD_INVALID', `服务${name}构建上下文无效`);
176
- const absoluteContext = path.resolve(projectRoot, context);
177
- const contextRelative = path.relative(projectRoot, absoluteContext);
178
- invariant(contextRelative === '' || (!contextRelative.startsWith('..') && !path.isAbsolute(contextRelative)), 'IDP_LOCAL_COMPOSE_BUILD_OUTSIDE_PROJECT', `服务${name}构建上下文必须位于项目内`);
173
+ const absoluteContext = path.isAbsolute(context) ? context : path.resolve(projectRoot, context);
174
+ const contextRelative = path.relative(sourceRoot, absoluteContext);
175
+ invariant(contextRelative === '' || (!contextRelative.startsWith('..') && !path.isAbsolute(contextRelative)), 'IDP_LOCAL_COMPOSE_BUILD_OUTSIDE_WORKSPACE', `服务${name}构建上下文必须位于Workspace源码边界内`);
179
176
  }
180
177
  if (service.healthcheck && !service.healthcheck.disable) healthchecks += 1;
181
178
  for (const [key, value] of Object.entries(service.environment ?? {})) {
@@ -199,24 +196,27 @@ function validateCompose(document, projectRoot) {
199
196
 
200
197
  function planIdentity(core) { return sha256(stableJson(core)); }
201
198
 
202
- export function createLocalSourcePlan({ configRoot, applicationConfigRoot, projectRoot, composeFile, output, runner = run, now = new Date().toISOString() }) {
199
+ export function createLocalSourcePlan({ configRoot, appConfigRoot, applicationConfigRoot, projectRoot, workspaceRoot, environment = 'local', composeFile, output, runner = run, now = new Date().toISOString() }) {
203
200
  const project = readProject(projectRoot);
201
+ const sourceRoot = workspaceRoot ? fs.realpathSync.native(workspaceRoot) : project.root;
202
+ const sourceRelative = path.relative(sourceRoot, project.root);
203
+ invariant(sourceRelative === '' || (!sourceRelative.startsWith('..') && !path.isAbsolute(sourceRelative)), 'IDP_LOCAL_PROJECT_OUTSIDE_WORKSPACE', '项目必须位于--workspace-root内');
204
204
  invariant(path.isAbsolute(composeFile ?? ''), 'IDP_LOCAL_COMPOSE_NOT_ABSOLUTE', '--compose-file必须是绝对路径');
205
205
  const composePath = fs.realpathSync.native(composeFile);
206
206
  const relativeCompose = path.relative(project.root, composePath);
207
207
  invariant(relativeCompose && !relativeCompose.startsWith('..') && !path.isAbsolute(relativeCompose), 'IDP_LOCAL_COMPOSE_OUTSIDE_PROJECT', 'Compose文件必须位于项目内');
208
208
  assertSafeRegularFile(composePath, 0o644, { allowEmpty: false, role: '本地Compose文件' });
209
- const config = inspectApplicationConfig(configRoot, project.contract, applicationConfigRoot);
210
- const composeProject = `idp-${project.contract.application.id}-local`.replace(/[^a-z0-9_-]/gu, '-').slice(0, 63);
211
- const normalized = normalizedCompose({ projectRoot: project.root, composeFile: composePath, envFile: config.envFile, composeProject, runner });
209
+ const config = inspectApplicationConfig(configRoot, project.contract, { applicationConfigRoot, appConfigRoot, projectRoot: project.root, workspaceRoot, environment });
210
+ const composeProject = `idp-${project.contract.application.id}-${environment}`.replace(/[^a-z0-9_-]/gu, '-').slice(0, 63);
211
+ const normalized = normalizedCompose({ projectRoot: project.root, sourceRoot, composeFile: composePath, envFile: config.envFile, composeProject, runner });
212
212
  const core = {
213
213
  schemaVersion: PLAN_SCHEMA, mode: 'local-source', promotable: false, application: project.contract.application.id,
214
- project: { root: project.root, sourceDigest: projectSourceDigest(project.root), manifest: { path: project.manifestFile, digest: hashFile(project.manifestFile) }, applicationConfigContract: { path: project.contractFile, digest: hashFile(project.contractFile) } },
214
+ project: { root: project.root, workspaceRoot: sourceRoot, unitPath: sourceRelative.split(path.sep).join('/') || '.', sourceDigest: projectSourceDigest(sourceRoot), manifest: { path: project.manifestFile, digest: hashFile(project.manifestFile) }, applicationConfigContract: { path: project.contractFile, digest: hashFile(project.contractFile) } },
215
215
  config: { directory: config.app, environmentFile: config.envFile, environmentDigest: config.envDigest, fileBindings: config.fileBindings },
216
216
  compose: { path: composePath, sourceDigest: hashFile(composePath), normalizedDigest: sha256(normalized.bytes) }, composeProject, createdAt: now,
217
217
  };
218
218
  const plan = { ...core, planId: planIdentity(core) };
219
- const { root } = applicationDirectory(configRoot, project.contract.application.id, { applicationConfigRoot });
219
+ const { root } = applicationDirectory(configRoot, project.contract.application.id, { applicationConfigRoot, appConfigRoot, projectRoot: project.root, workspaceRoot, environment });
220
220
  invariant(path.isAbsolute(output ?? ''), 'IDP_LOCAL_PLAN_OUTPUT_NOT_ABSOLUTE', '--output必须是绝对路径');
221
221
  const plans = resolveContained(root, 'plans/local-applications', '本地应用Plan目录');
222
222
  secureDirectoryRoot(plans, '本地应用Plan目录', { create: true });
@@ -228,27 +228,28 @@ export function createLocalSourcePlan({ configRoot, applicationConfigRoot, proje
228
228
  return plan;
229
229
  }
230
230
 
231
- function readPlan(configRoot, planFile, applicationConfigRoot) {
231
+ function readPlan(configRoot, planFile, applicationConfigRoot, appConfigRoot) {
232
232
  invariant(path.isAbsolute(planFile ?? ''), 'IDP_LOCAL_PLAN_NOT_ABSOLUTE', '--plan必须是绝对路径');
233
- const { root } = applicationDirectory(configRoot, 'placeholder', { applicationConfigRoot });
234
- const plans = resolveContained(root, 'plans/local-applications', '本地应用Plan目录');
235
233
  const candidate = fs.realpathSync.native(planFile);
236
- const relative = path.relative(plans, candidate);
237
- invariant(relative && !relative.startsWith('..') && !path.isAbsolute(relative), 'IDP_LOCAL_PLAN_OUTSIDE_CONFIG', 'Plan必须位于IDP_CONFIG_DIR/plans/local-applications');
238
234
  assertSafeRegularFile(candidate, 0o600, { allowEmpty: false, role: 'Local DeploymentPlan' });
239
235
  const plan = readJson(candidate, 'Local DeploymentPlan');
240
236
  invariant(plan.schemaVersion === PLAN_SCHEMA && plan.mode === 'local-source' && plan.promotable === false, 'IDP_LOCAL_PLAN_INVALID', 'Local DeploymentPlan版本无效');
241
237
  const { planId, ...core } = plan;
242
238
  invariant(planIdentity(core) === planId, 'IDP_LOCAL_PLAN_DIGEST_INVALID', 'Local DeploymentPlan摘要无效');
243
- return { plan, candidate, root };
239
+ const root = applicationConfigRoot ? secureDirectoryRoot(applicationConfigRoot, '应用最终Config Dir') : appConfigRoot ? secureDirectoryRoot(appConfigRoot, 'APP_CONFIG_DIR') : secureDirectoryRoot(configRoot, 'IDP_CONFIG_DIR');
240
+ const plans = appConfigRoot && !applicationConfigRoot ? path.join(plan.config.directory, 'plans/local-applications') : resolveContained(root, 'plans/local-applications', '本地应用Plan目录');
241
+ const relative = path.relative(plans, candidate);
242
+ invariant(relative && !relative.startsWith('..') && !path.isAbsolute(relative), 'IDP_LOCAL_PLAN_OUTSIDE_CONFIG', 'Plan必须位于应用Config Dir的plans/local-applications');
243
+ const runtimeRoot = appConfigRoot && !applicationConfigRoot ? plan.config.directory : root;
244
+ return { plan, candidate, root: runtimeRoot };
244
245
  }
245
246
 
246
247
  function revalidate(plan, runner) {
247
- invariant(projectSourceDigest(plan.project.root) === plan.project.sourceDigest, 'IDP_LOCAL_SOURCE_CHANGED', '项目构建源已变化,请重新生成Plan');
248
+ invariant(projectSourceDigest(plan.project.workspaceRoot ?? plan.project.root) === plan.project.sourceDigest, 'IDP_LOCAL_SOURCE_CHANGED', 'Workspace构建源已变化,请重新生成Plan');
248
249
  invariant(hashFile(plan.project.manifest.path) === plan.project.manifest.digest && hashFile(plan.project.applicationConfigContract.path) === plan.project.applicationConfigContract.digest && hashFile(plan.compose.path) === plan.compose.sourceDigest, 'IDP_LOCAL_INPUT_CHANGED', '项目Manifest、配置合同或Compose已变化,请重新生成Plan');
249
250
  invariant(hashFile(plan.config.environmentFile) === plan.config.environmentDigest, 'IDP_LOCAL_CONFIG_CHANGED', '应用.env已变化,请重新生成Plan');
250
251
  for (const binding of plan.config.fileBindings) invariant(hashFile(path.join(plan.config.directory, binding.path)) === binding.digest, 'IDP_LOCAL_CONFIG_CHANGED', `${binding.name}已变化,请重新生成Plan`);
251
- const normalized = normalizedCompose({ projectRoot: plan.project.root, composeFile: plan.compose.path, envFile: plan.config.environmentFile, composeProject: plan.composeProject, runner });
252
+ const normalized = normalizedCompose({ projectRoot: plan.project.root, sourceRoot: plan.project.workspaceRoot ?? plan.project.root, composeFile: plan.compose.path, envFile: plan.config.environmentFile, composeProject: plan.composeProject, runner });
252
253
  invariant(sha256(normalized.bytes) === plan.compose.normalizedDigest, 'IDP_LOCAL_COMPOSE_RENDER_CHANGED', 'Compose规范化结果已变化,请重新生成Plan');
253
254
  return normalized;
254
255
  }
@@ -289,11 +290,11 @@ function activeState(root, plan, { required = true } = {}) {
289
290
  return { candidate, bytes, state };
290
291
  }
291
292
 
292
- export function verifyLocalSourceDeployment({ configRoot, applicationConfigRoot, planFile, runner = run, requireActiveState = true }) {
293
- const { plan, root } = readPlan(configRoot, planFile, applicationConfigRoot);
293
+ export function verifyLocalSourceDeployment({ configRoot, appConfigRoot, applicationConfigRoot, planFile, runner = run, requireActiveState = true }) {
294
+ const { plan, root } = readPlan(configRoot, planFile, applicationConfigRoot, appConfigRoot);
294
295
  revalidate(plan, runner);
295
296
  activeState(root, plan, { required: requireActiveState });
296
- const ps = parsePs(executeCompose(runner, composeArgs(plan, ['ps', '--format', 'json']), plan.project.root, true).stdout);
297
+ const ps = parsePs(executeCompose(runner, composeArgs(plan, ['ps', '--format', 'json']), plan.project.workspaceRoot ?? plan.project.root, true).stdout);
297
298
  invariant(ps.length > 0, 'IDP_LOCAL_DEPLOYMENT_NOT_RUNNING', '本地应用没有运行中的Compose服务');
298
299
  for (const service of ps) {
299
300
  const state = String(service.State ?? service.state ?? '').toLowerCase();
@@ -304,8 +305,8 @@ export function verifyLocalSourceDeployment({ configRoot, applicationConfigRoot,
304
305
  return evidence(root, plan, 'verify', 'verified', { services: ps.map((item) => item.Service ?? item.Name).filter(Boolean) });
305
306
  }
306
307
 
307
- export function applyLocalSourceDeployment({ configRoot, applicationConfigRoot, planFile, runner = run }) {
308
- const { plan, root, candidate: canonicalPlanFile } = readPlan(configRoot, planFile, applicationConfigRoot);
308
+ export function applyLocalSourceDeployment({ configRoot, appConfigRoot, applicationConfigRoot, planFile, runner = run }) {
309
+ const { plan, root, candidate: canonicalPlanFile } = readPlan(configRoot, planFile, applicationConfigRoot, appConfigRoot);
309
310
  revalidate(plan, runner);
310
311
  const current = activeState(root, plan, { required: false });
311
312
  const stateFile = current.candidate;
@@ -313,41 +314,41 @@ export function applyLocalSourceDeployment({ configRoot, applicationConfigRoot,
313
314
  const preimageFile = path.join(path.dirname(stateFile), `.${plan.application}.${plan.planId.slice(7, 19)}.preimage.json`);
314
315
  if (before) atomicWrite(preimageFile, before, 0o600);
315
316
  try {
316
- executeCompose(runner, composeArgs(plan, ['build']), plan.project.root, false);
317
- executeCompose(runner, composeArgs(plan, ['up', '-d', '--wait', '--remove-orphans']), plan.project.root, false);
317
+ executeCompose(runner, composeArgs(plan, ['build']), plan.project.workspaceRoot ?? plan.project.root, false);
318
+ executeCompose(runner, composeArgs(plan, ['up', '-d', '--wait', '--remove-orphans']), plan.project.workspaceRoot ?? plan.project.root, false);
318
319
  const afterCommands = fs.existsSync(stateFile) ? fs.readFileSync(stateFile) : null;
319
320
  invariant((before === null && afterCommands === null) || (before !== null && afterCommands !== null && before.equals(afterCommands)), 'IDP_LOCAL_RUNTIME_STATE_CHANGED', '执行期间运行态被并发修改,拒绝提交Receipt');
320
- const receipt = verifyLocalSourceDeployment({ configRoot, applicationConfigRoot, planFile, runner, requireActiveState: false });
321
+ const receipt = verifyLocalSourceDeployment({ configRoot, appConfigRoot, applicationConfigRoot, planFile, runner, requireActiveState: false });
321
322
  atomicWrite(stateFile, `${JSON.stringify({ schemaVersion: 'idp.local-source-runtime/v1', application: plan.application, planId: plan.planId, composeProject: plan.composeProject, composeFile: plan.compose.path, planFile: canonicalPlanFile, receiptId: receipt.receiptId }, null, 2)}\n`, 0o600);
322
323
  return evidence(root, plan, 'apply', 'verified', { verifyReceiptId: receipt.receiptId });
323
324
  } catch (error) {
324
325
  try {
325
326
  if (before && current.state?.planFile) {
326
- const previous = readPlan(configRoot, current.state.planFile, applicationConfigRoot).plan;
327
+ const previous = readPlan(configRoot, current.state.planFile, applicationConfigRoot, appConfigRoot).plan;
327
328
  revalidate(previous, runner);
328
- executeCompose(runner, composeArgs(previous, ['up', '-d', '--wait', '--remove-orphans']), previous.project.root, false);
329
+ executeCompose(runner, composeArgs(previous, ['up', '-d', '--wait', '--remove-orphans']), previous.project.workspaceRoot ?? previous.project.root, false);
329
330
  }
330
- else executeCompose(runner, composeArgs(plan, ['down', '--remove-orphans']), plan.project.root, false);
331
+ else executeCompose(runner, composeArgs(plan, ['down', '--remove-orphans']), plan.project.workspaceRoot ?? plan.project.root, false);
331
332
  } catch {}
332
333
  evidence(root, plan, 'apply', 'failed', { code: error.code ?? 'IDP_PROCESS_FAILED' });
333
334
  throw error;
334
335
  }
335
336
  }
336
337
 
337
- export function stopLocalSourceDeployment({ configRoot, applicationConfigRoot, planFile, runner = run }) {
338
- const { plan, root } = readPlan(configRoot, planFile, applicationConfigRoot);
338
+ export function stopLocalSourceDeployment({ configRoot, appConfigRoot, applicationConfigRoot, planFile, runner = run }) {
339
+ const { plan, root } = readPlan(configRoot, planFile, applicationConfigRoot, appConfigRoot);
339
340
  revalidate(plan, runner);
340
341
  activeState(root, plan);
341
- executeCompose(runner, composeArgs(plan, ['stop']), plan.project.root, false);
342
+ executeCompose(runner, composeArgs(plan, ['stop']), plan.project.workspaceRoot ?? plan.project.root, false);
342
343
  return evidence(root, plan, 'stop', 'stopped');
343
344
  }
344
345
 
345
- export function removeLocalSourceDeployment({ configRoot, applicationConfigRoot, planFile, confirmation, runner = run }) {
346
- const { plan, root } = readPlan(configRoot, planFile, applicationConfigRoot);
346
+ export function removeLocalSourceDeployment({ configRoot, appConfigRoot, applicationConfigRoot, planFile, confirmation, runner = run }) {
347
+ const { plan, root } = readPlan(configRoot, planFile, applicationConfigRoot, appConfigRoot);
347
348
  invariant(confirmation === plan.application, 'IDP_LOCAL_REMOVE_CONFIRMATION_REQUIRED', `remove必须通过--confirm ${plan.application}精确确认`);
348
349
  revalidate(plan, runner);
349
350
  activeState(root, plan);
350
- executeCompose(runner, composeArgs(plan, ['down', '--remove-orphans']), plan.project.root, false);
351
+ executeCompose(runner, composeArgs(plan, ['down', '--remove-orphans']), plan.project.workspaceRoot ?? plan.project.root, false);
351
352
  const candidate = statePath(root, plan.application);
352
353
  if (fs.existsSync(candidate)) fs.unlinkSync(candidate);
353
354
  return evidence(root, plan, 'remove', 'removed', { volumesRemoved: false });
@@ -25,7 +25,7 @@ export function initializeWorkbenchProjects({ configRoot: input }) {
25
25
  return { schemaVersion: 'idp.workbench-project-candidate/v1', status: 'ready', candidate };
26
26
  }
27
27
 
28
- function readCandidate(root, file) {
28
+ function readCandidate(root, file, appConfigRoot) {
29
29
  invariant(path.isAbsolute(file ?? ''), 'IDP_WORKBENCH_CANDIDATE_NOT_ABSOLUTE', '--file必须是绝对路径');
30
30
  const workbench = resolveContained(root, 'workbench', '工作台配置目录'); const candidate = fs.realpathSync.native(file);
31
31
  const relative = path.relative(workbench, candidate); invariant(relative && !relative.startsWith('..') && !path.isAbsolute(relative), 'IDP_WORKBENCH_CANDIDATE_OUTSIDE_CONFIG', 'candidate必须位于IDP_CONFIG_DIR/workbench');
@@ -46,7 +46,8 @@ function readCandidate(root, file) {
46
46
  const applicationRoot = fs.realpathSync.native(path.resolve(hostPath, relativePath));
47
47
  invariant(applicationRoot === hostPath || applicationRoot.startsWith(`${hostPath}${path.sep}`), 'IDP_WORKBENCH_PROJECT_RELATIVE_PATH_ESCAPE', `项目${item.id}的应用路径逃逸项目目录`);
48
48
  invariant(fs.existsSync(path.join(applicationRoot, 'project-capabilities.json')), 'IDP_WORKBENCH_APPLICATION_INVALID', `项目${item.id}的${relativePath}缺少project-capabilities.json`);
49
- const applicationConfigDir = safeDirectory(binding.applicationConfigDir, `项目${item.id}/${relativePath} Config Dir`); assertOutsideRepositories(applicationConfigDir, `项目${item.id}/${relativePath} Config Dir`);
49
+ const derived = appConfigRoot ? path.join(appConfigRoot, item.id, ...(relativePath === '.' ? [] : relativePath.split('/')), 'local') : undefined;
50
+ const applicationConfigDir = safeDirectory(binding.applicationConfigDir ?? derived, `项目${item.id}/${relativePath} Config Dir`); assertOutsideRepositories(applicationConfigDir, `项目${item.id}/${relativePath} Config Dir`);
50
51
  return { relativePath, applicationConfigDir, containerPath: relativePath === '.' ? `/workspaces/${item.id}` : `/workspaces/${item.id}/${relativePath}`, configDirectory: `/config/applications/${item.id}-${index}/local` };
51
52
  });
52
53
  return { id: item.id, hostPath, containerPath: `/workspaces/${item.id}`, configBindings };
@@ -54,10 +55,12 @@ function readCandidate(root, file) {
54
55
  return { candidate, projects };
55
56
  }
56
57
 
57
- export function createWorkbenchProjectsPlan({ configRoot: input, dyytoRoot, candidateFile, output, now = new Date().toISOString() }) {
58
+ export function createWorkbenchProjectsPlan({ configRoot: input, appConfigRoot, dyytoRoot, candidateFile, output, now = new Date().toISOString() }) {
58
59
  const root = configRoot(input), dyyto = safeDirectory(dyytoRoot, 'Dyyto仓库');
59
60
  invariant(fs.existsSync(path.join(dyyto, 'compose.yaml')), 'IDP_WORKBENCH_DYYTO_INVALID', 'Dyyto仓库缺少compose.yaml');
60
- const candidate = readCandidate(root, candidateFile);
61
+ const applications = appConfigRoot ? safeDirectory(appConfigRoot, 'APP_CONFIG_DIR') : undefined;
62
+ if (applications) assertOutsideRepositories(applications, 'APP_CONFIG_DIR');
63
+ const candidate = readCandidate(root, candidateFile, applications);
61
64
  const core = { schemaVersion: 'idp.workbench-project-plan/v1', dyytoRoot: dyyto, dyytoComposeDigest: hashFile(path.join(dyyto, 'compose.yaml')), candidate: { path: candidate.candidate, digest: hashFile(candidate.candidate) }, projects: candidate.projects, createdAt: now };
62
65
  const plan = { ...core, planId: sha256(stableJson(core)) };
63
66
  const plans = resolveContained(root, 'plans/workbench-projects', '工作台Plan目录'); secureDirectoryRoot(plans, '工作台Plan目录', { create: true });